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,298 @@
1
+ /**
2
+ * Accessible Offcanvas Component
3
+ * Vanilla JS, a11y friendly, focus trap, keyboard navigation
4
+ * Follows WAI-ARIA Authoring Practices Guide for Dialog Pattern
5
+ */
6
+
7
+ class AccessibleOffcanvas {
8
+ constructor(element, options = {}) {
9
+ this.container = element;
10
+ this.triggers = Array.from(
11
+ document.querySelectorAll(`[data-offcanvas-trigger="${element.id}"]`)
12
+ );
13
+ this.panel = element.querySelector("[data-offcanvas-panel]");
14
+ this.backdrop = element.querySelector("[data-offcanvas-backdrop]");
15
+ this.closeButtons = Array.from(
16
+ this.panel.querySelectorAll("[data-offcanvas-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.panel.addEventListener("keydown", (e) =>
68
+ this.handleFocusTrap(e)
69
+ );
70
+ }
71
+ }
72
+
73
+ setupAria() {
74
+ // Generate unique IDs if not present
75
+ if (!this.container.id) {
76
+ this.container.id = `offcanvas-${Math.random()
77
+ .toString(36)
78
+ .substr(2, 9)}`;
79
+ }
80
+ if (!this.panel.id) {
81
+ this.panel.id = `${this.container.id}-panel`;
82
+ }
83
+
84
+ // Panel ARIA attributes
85
+ this.panel.setAttribute("role", "dialog");
86
+ this.panel.setAttribute("aria-modal", "true");
87
+ this.panel.setAttribute("aria-hidden", "true");
88
+ this.panel.setAttribute("tabindex", "-1");
89
+
90
+ // Find title for aria-labelledby
91
+ const title = this.panel.querySelector("[data-offcanvas-title]");
92
+ if (title) {
93
+ if (!title.id) {
94
+ title.id = `${this.container.id}-title`;
95
+ }
96
+ this.panel.setAttribute("aria-labelledby", title.id);
97
+ }
98
+
99
+ // Backdrop ARIA
100
+ if (this.backdrop) {
101
+ this.backdrop.setAttribute("aria-hidden", "true");
102
+ }
103
+
104
+ // Trigger ARIA
105
+ this.triggers.forEach((trigger) => {
106
+ trigger.setAttribute("aria-controls", this.panel.id);
107
+ trigger.setAttribute("aria-expanded", "false");
108
+ });
109
+ }
110
+
111
+ open(e) {
112
+ if (e) {
113
+ e.preventDefault();
114
+ this.lastFocusedElement = e.target;
115
+ }
116
+
117
+ if (this.isOpen) return;
118
+
119
+ this.isOpen = true;
120
+ this.panel.setAttribute("aria-hidden", "false");
121
+ if (this.backdrop) {
122
+ this.backdrop.setAttribute("aria-hidden", "false");
123
+ }
124
+
125
+ this.triggers.forEach((trigger) => {
126
+ trigger.setAttribute("aria-expanded", "true");
127
+ });
128
+
129
+ // Scroll lock
130
+ if (this.options.scrollLock) {
131
+ document.body.classList.add("offcanvas-open");
132
+ }
133
+
134
+ // Update focusable elements
135
+ this.updateFocusableElements();
136
+
137
+ // Focus first element
138
+ setTimeout(() => {
139
+ if (this.firstFocusable) {
140
+ this.firstFocusable.focus();
141
+ } else {
142
+ this.panel.focus();
143
+ }
144
+ }, 100);
145
+
146
+ // Callback
147
+ if (this.options.onOpen) {
148
+ this.options.onOpen(this);
149
+ }
150
+ }
151
+
152
+ close(e) {
153
+ if (e) {
154
+ e.preventDefault();
155
+ }
156
+
157
+ if (!this.isOpen) return;
158
+
159
+ this.isOpen = false;
160
+ this.panel.setAttribute("aria-hidden", "true");
161
+ if (this.backdrop) {
162
+ this.backdrop.setAttribute("aria-hidden", "true");
163
+ }
164
+
165
+ this.triggers.forEach((trigger) => {
166
+ trigger.setAttribute("aria-expanded", "false");
167
+ });
168
+
169
+ // Scroll lock
170
+ if (this.options.scrollLock) {
171
+ document.body.classList.remove("offcanvas-open");
172
+ }
173
+
174
+ // Return focus to trigger
175
+ if (this.lastFocusedElement) {
176
+ this.lastFocusedElement.focus();
177
+ }
178
+
179
+ // Callback
180
+ if (this.options.onClose) {
181
+ this.options.onClose(this);
182
+ }
183
+ }
184
+
185
+ updateFocusableElements() {
186
+ // Find all focusable elements
187
+ const focusableSelectors = [
188
+ 'a[href]',
189
+ 'button:not([disabled])',
190
+ 'textarea:not([disabled])',
191
+ 'input:not([disabled])',
192
+ 'select:not([disabled])',
193
+ '[tabindex]:not([tabindex="-1"])',
194
+ ];
195
+
196
+ this.focusableElements = Array.from(
197
+ this.panel.querySelectorAll(focusableSelectors.join(","))
198
+ ).filter((el) => {
199
+ return (
200
+ el.offsetWidth > 0 ||
201
+ el.offsetHeight > 0 ||
202
+ el.getClientRects().length > 0
203
+ );
204
+ });
205
+
206
+ this.firstFocusable = this.focusableElements[0] || null;
207
+ this.lastFocusable =
208
+ this.focusableElements[this.focusableElements.length - 1] || null;
209
+ }
210
+
211
+ handleFocusTrap(e) {
212
+ if (!this.isOpen || e.key !== "Tab") return;
213
+
214
+ // If no focusable elements, prevent default
215
+ if (this.focusableElements.length === 0) {
216
+ e.preventDefault();
217
+ return;
218
+ }
219
+
220
+ // Shift + Tab (backward)
221
+ if (e.shiftKey) {
222
+ if (document.activeElement === this.firstFocusable) {
223
+ e.preventDefault();
224
+ this.lastFocusable.focus();
225
+ }
226
+ }
227
+ // Tab (forward)
228
+ else {
229
+ if (document.activeElement === this.lastFocusable) {
230
+ e.preventDefault();
231
+ this.firstFocusable.focus();
232
+ }
233
+ }
234
+ }
235
+
236
+ destroy() {
237
+ // Remove event listeners
238
+ this.triggers.forEach((trigger) => {
239
+ trigger.removeEventListener("click", this.open);
240
+ });
241
+
242
+ this.closeButtons.forEach((button) => {
243
+ button.removeEventListener("click", this.close);
244
+ });
245
+
246
+ if (this.backdrop) {
247
+ this.backdrop.removeEventListener("click", this.close);
248
+ }
249
+
250
+ // Remove scroll lock
251
+ if (this.options.scrollLock) {
252
+ document.body.classList.remove("offcanvas-open");
253
+ }
254
+
255
+ // Close if open
256
+ if (this.isOpen) {
257
+ this.close();
258
+ }
259
+ }
260
+ }
261
+
262
+ // Auto-initialize offcanvas
263
+ function initOffcanvas() {
264
+ const offcanvasElements = document.querySelectorAll("[data-offcanvas]");
265
+ const instances = [];
266
+
267
+ offcanvasElements.forEach((element) => {
268
+ const options = {
269
+ closeOnBackdrop: element.dataset.closeOnBackdrop !== "false",
270
+ closeOnEscape: element.dataset.closeOnEscape !== "false",
271
+ trapFocus: element.dataset.trapFocus !== "false",
272
+ scrollLock: element.dataset.scrollLock !== "false",
273
+ };
274
+
275
+ instances.push(new AccessibleOffcanvas(element, options));
276
+ });
277
+
278
+ return instances;
279
+ }
280
+
281
+ // Initialize on DOM ready (only if not using module bundler)
282
+ if (typeof window !== 'undefined' && !window.a11yKitManualInit) {
283
+ if (document.readyState === 'loading') {
284
+ document.addEventListener('DOMContentLoaded', initOffcanvas);
285
+ } else {
286
+ initOffcanvas();
287
+ }
288
+ }
289
+
290
+ // Register in global namespace for CDN usage
291
+ if (typeof window !== 'undefined') {
292
+ window.a11yKit = window.a11yKit || {};
293
+ window.a11yKit.Offcanvas = AccessibleOffcanvas;
294
+ window.a11yKit.initOffcanvas = initOffcanvas;
295
+ }
296
+
297
+ // ES6 exports with short aliases
298
+ export { AccessibleOffcanvas, AccessibleOffcanvas as Offcanvas, initOffcanvas };
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Accessible Tabs Component
3
+ * Vanilla JS, a11y friendly, keyboard navigation support
4
+ * Follows WAI-ARIA Authoring Practices Guide for Tabs Pattern
5
+ */
6
+
7
+ class AccessibleTabs {
8
+ constructor(element, options = {}) {
9
+ this.container = element;
10
+ this.tablist = element.querySelector("[data-tabs-list]");
11
+ this.tabs = Array.from(
12
+ this.tablist.querySelectorAll("[data-tabs-tab]")
13
+ );
14
+ this.panels = Array.from(
15
+ element.querySelectorAll("[data-tabs-panel]")
16
+ );
17
+
18
+ // Options
19
+ this.options = {
20
+ activeIndex: options.activeIndex || 0,
21
+ automatic: options.automatic !== false, // Automatic activation on arrow keys
22
+ orientation: options.orientation || "horizontal", // horizontal or vertical
23
+ onChange: options.onChange || null,
24
+ onTabClick: options.onTabClick || null,
25
+ };
26
+
27
+ this.currentIndex = this.options.activeIndex;
28
+
29
+ this.init();
30
+ }
31
+
32
+ init() {
33
+ // Set up ARIA attributes
34
+ this.setupAria();
35
+
36
+ // Activate initial tab
37
+ this.activateTab(this.currentIndex, false);
38
+
39
+ // Bind event listeners
40
+ this.tabs.forEach((tab, index) => {
41
+ tab.addEventListener("click", (e) => this.handleTabClick(e, index));
42
+ tab.addEventListener("keydown", (e) =>
43
+ this.handleTabKeydown(e, index)
44
+ );
45
+ });
46
+ }
47
+
48
+ setupAria() {
49
+ // Generate unique IDs if not present
50
+ const baseId = `tabs-${Math.random().toString(36).substr(2, 9)}`;
51
+
52
+ // Setup tablist
53
+ this.tablist.setAttribute("role", "tablist");
54
+ const orientation =
55
+ this.container.classList.contains("tabs--vertical")
56
+ ? "vertical"
57
+ : "horizontal";
58
+ this.tablist.setAttribute("aria-orientation", orientation);
59
+
60
+ // Setup tabs and panels
61
+ this.tabs.forEach((tab, index) => {
62
+ const panel = this.panels[index];
63
+
64
+ // Generate IDs
65
+ if (!tab.id) {
66
+ tab.id = `${baseId}-tab-${index}`;
67
+ }
68
+ if (!panel.id) {
69
+ panel.id = `${baseId}-panel-${index}`;
70
+ }
71
+
72
+ // Tab attributes
73
+ tab.setAttribute("role", "tab");
74
+ tab.setAttribute("aria-controls", panel.id);
75
+ tab.setAttribute("aria-selected", "false");
76
+ tab.setAttribute("tabindex", "-1");
77
+
78
+ // Check if tab is disabled
79
+ if (
80
+ tab.hasAttribute("disabled") ||
81
+ tab.hasAttribute("aria-disabled")
82
+ ) {
83
+ tab.setAttribute("aria-disabled", "true");
84
+ }
85
+
86
+ // Panel attributes
87
+ panel.setAttribute("role", "tabpanel");
88
+ panel.setAttribute("aria-labelledby", tab.id);
89
+ panel.setAttribute("tabindex", "0");
90
+ panel.setAttribute("aria-hidden", "true");
91
+ });
92
+ }
93
+
94
+ handleTabClick(e, index) {
95
+ e.preventDefault();
96
+
97
+ // Don't activate disabled tabs
98
+ if (this.isTabDisabled(index)) {
99
+ return;
100
+ }
101
+
102
+ this.activateTab(index);
103
+
104
+ if (this.options.onTabClick) {
105
+ this.options.onTabClick(this.tabs[index], index, this);
106
+ }
107
+ }
108
+
109
+ handleTabKeydown(e, index) {
110
+ const orientation = this.tablist.getAttribute("aria-orientation");
111
+ let targetIndex = null;
112
+
113
+ switch (e.key) {
114
+ case "ArrowLeft":
115
+ if (orientation === "horizontal") {
116
+ e.preventDefault();
117
+ targetIndex = this.getPreviousEnabledTabIndex(index);
118
+ }
119
+ break;
120
+
121
+ case "ArrowRight":
122
+ if (orientation === "horizontal") {
123
+ e.preventDefault();
124
+ targetIndex = this.getNextEnabledTabIndex(index);
125
+ }
126
+ break;
127
+
128
+ case "ArrowUp":
129
+ if (orientation === "vertical") {
130
+ e.preventDefault();
131
+ targetIndex = this.getPreviousEnabledTabIndex(index);
132
+ }
133
+ break;
134
+
135
+ case "ArrowDown":
136
+ if (orientation === "vertical") {
137
+ e.preventDefault();
138
+ targetIndex = this.getNextEnabledTabIndex(index);
139
+ }
140
+ break;
141
+
142
+ case "Home":
143
+ e.preventDefault();
144
+ targetIndex = this.getFirstEnabledTabIndex();
145
+ break;
146
+
147
+ case "End":
148
+ e.preventDefault();
149
+ targetIndex = this.getLastEnabledTabIndex();
150
+ break;
151
+
152
+ case "Enter":
153
+ case " ":
154
+ e.preventDefault();
155
+ if (!this.isTabDisabled(index)) {
156
+ this.activateTab(index);
157
+ }
158
+ return;
159
+ }
160
+
161
+ // Move focus and optionally activate
162
+ if (targetIndex !== null) {
163
+ this.tabs[targetIndex].focus();
164
+
165
+ // Automatic activation
166
+ if (this.options.automatic && !this.isTabDisabled(targetIndex)) {
167
+ this.activateTab(targetIndex);
168
+ }
169
+ }
170
+ }
171
+
172
+ activateTab(index, focus = true) {
173
+ if (index < 0 || index >= this.tabs.length) {
174
+ return;
175
+ }
176
+
177
+ // Don't activate disabled tabs
178
+ if (this.isTabDisabled(index)) {
179
+ return;
180
+ }
181
+
182
+ const previousIndex = this.currentIndex;
183
+
184
+ // Function to update DOM
185
+ const updateDOM = () => {
186
+ // Deactivate all tabs and panels
187
+ this.tabs.forEach((tab, i) => {
188
+ const isActive = i === index;
189
+
190
+ tab.setAttribute("aria-selected", isActive ? "true" : "false");
191
+ tab.setAttribute("tabindex", isActive ? "0" : "-1");
192
+
193
+ this.panels[i].setAttribute("aria-hidden", isActive ? "false" : "true");
194
+ });
195
+
196
+ this.currentIndex = index;
197
+
198
+ // Focus the activated tab
199
+ if (focus) {
200
+ this.tabs[index].focus();
201
+ }
202
+
203
+ // Callback
204
+ if (this.options.onChange && previousIndex !== index) {
205
+ this.options.onChange(
206
+ this.tabs[index],
207
+ this.panels[index],
208
+ index,
209
+ this
210
+ );
211
+ }
212
+ };
213
+
214
+ // Use View Transitions API if available and animations are enabled
215
+ if (!this.container.hasAttribute('data-no-animation') &&
216
+ document.startViewTransition &&
217
+ !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
218
+ document.startViewTransition(() => updateDOM());
219
+ } else {
220
+ updateDOM();
221
+ }
222
+ }
223
+
224
+ isTabDisabled(index) {
225
+ const tab = this.tabs[index];
226
+ return (
227
+ tab.hasAttribute("disabled") ||
228
+ tab.getAttribute("aria-disabled") === "true"
229
+ );
230
+ }
231
+
232
+ getNextEnabledTabIndex(currentIndex) {
233
+ let nextIndex = currentIndex + 1;
234
+
235
+ // Loop to beginning
236
+ if (nextIndex >= this.tabs.length) {
237
+ nextIndex = 0;
238
+ }
239
+
240
+ // Skip disabled tabs
241
+ while (this.isTabDisabled(nextIndex) && nextIndex !== currentIndex) {
242
+ nextIndex++;
243
+ if (nextIndex >= this.tabs.length) {
244
+ nextIndex = 0;
245
+ }
246
+ }
247
+
248
+ return nextIndex;
249
+ }
250
+
251
+ getPreviousEnabledTabIndex(currentIndex) {
252
+ let prevIndex = currentIndex - 1;
253
+
254
+ // Loop to end
255
+ if (prevIndex < 0) {
256
+ prevIndex = this.tabs.length - 1;
257
+ }
258
+
259
+ // Skip disabled tabs
260
+ while (this.isTabDisabled(prevIndex) && prevIndex !== currentIndex) {
261
+ prevIndex--;
262
+ if (prevIndex < 0) {
263
+ prevIndex = this.tabs.length - 1;
264
+ }
265
+ }
266
+
267
+ return prevIndex;
268
+ }
269
+
270
+ getFirstEnabledTabIndex() {
271
+ for (let i = 0; i < this.tabs.length; i++) {
272
+ if (!this.isTabDisabled(i)) {
273
+ return i;
274
+ }
275
+ }
276
+ return 0;
277
+ }
278
+
279
+ getLastEnabledTabIndex() {
280
+ for (let i = this.tabs.length - 1; i >= 0; i--) {
281
+ if (!this.isTabDisabled(i)) {
282
+ return i;
283
+ }
284
+ }
285
+ return this.tabs.length - 1;
286
+ }
287
+
288
+ destroy() {
289
+ // Remove event listeners
290
+ this.tabs.forEach((tab, index) => {
291
+ tab.removeEventListener("click", (e) =>
292
+ this.handleTabClick(e, index)
293
+ );
294
+ tab.removeEventListener("keydown", (e) =>
295
+ this.handleTabKeydown(e, index)
296
+ );
297
+ });
298
+ }
299
+ }
300
+
301
+ // Auto-initialize tabs
302
+ function initTabs() {
303
+ const tabsElements = document.querySelectorAll("[data-tabs]");
304
+ const instances = [];
305
+
306
+ tabsElements.forEach((element) => {
307
+ const options = {
308
+ activeIndex: parseInt(element.dataset.activeIndex) || 0,
309
+ automatic: element.dataset.automatic !== "false",
310
+ orientation: element.dataset.orientation || "horizontal",
311
+ };
312
+
313
+ instances.push(new AccessibleTabs(element, options));
314
+ });
315
+
316
+ return instances;
317
+ }
318
+
319
+ // Initialize on DOM ready (only if not using module bundler)
320
+ if (typeof window !== 'undefined' && !window.a11yKitManualInit) {
321
+ if (document.readyState === 'loading') {
322
+ document.addEventListener('DOMContentLoaded', initTabs);
323
+ } else {
324
+ initTabs();
325
+ }
326
+ }
327
+
328
+ // Register in global namespace for CDN usage
329
+ if (typeof window !== 'undefined') {
330
+ window.a11yKit = window.a11yKit || {};
331
+ window.a11yKit.Tabs = AccessibleTabs;
332
+ window.a11yKit.initTabs = initTabs;
333
+ }
334
+
335
+ // ES6 exports with short aliases
336
+ export { AccessibleTabs, AccessibleTabs as Tabs, initTabs };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * a11y-kit
3
+ * Lightweight, accessible UI component library with full ARIA support
4
+ *
5
+ * @version 1.0.0
6
+ * @license MIT
7
+ */
8
+
9
+ // Export all components with both full and short aliases
10
+ export { AccessibleDropdown, Dropdown, initDropdowns } from './a11y-dropdown.js';
11
+ export { AccessibleTabs, Tabs, initTabs } from './a11y-tabs.js';
12
+ export { AccessibleOffcanvas, Offcanvas, initOffcanvas } from './a11y-offcanvas.js';
13
+ export { AccessibleModal, Modal, initModals } from './a11y-modal.js';
14
+ export { AccessibleCollapse, Collapse, initCollapses, AccessibleAccordion, Accordion, initAccordions } from './a11y-accordion.js';
15
+
16
+ /**
17
+ * Initialize all components at once
18
+ * Convenient helper for auto-initializing all a11y-kit components
19
+ */
20
+ export function initAll() {
21
+ const instances = {
22
+ dropdowns: initDropdowns(),
23
+ tabs: initTabs(),
24
+ offcanvas: initOffcanvas(),
25
+ modals: initModals(),
26
+ collapses: initCollapses(),
27
+ accordions: initAccordions()
28
+ };
29
+
30
+ return instances;
31
+ }
32
+
33
+ // Auto-initialize on DOM ready if not using module bundler
34
+ if (typeof window !== 'undefined' && !window.a11yKitManualInit) {
35
+ if (document.readyState === 'loading') {
36
+ document.addEventListener('DOMContentLoaded', initAll);
37
+ } else {
38
+ initAll();
39
+ }
40
+ }
41
+
42
+ // Register in global namespace for CDN usage
43
+ if (typeof window !== 'undefined') {
44
+ window.a11yKit = window.a11yKit || {};
45
+ window.a11yKit.initAll = initAll;
46
+ }