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,447 @@
1
+ /**
2
+ * Accessible Accordion/Collapse Component
3
+ * Vanilla JS, a11y friendly, keyboard navigation
4
+ * Follows WAI-ARIA Authoring Practices Guide for Accordion Pattern
5
+ *
6
+ * Two components:
7
+ * - Collapse: Standalone collapse toggles (button controls panel via data-collapse-toggle)
8
+ * - Accordion: Accordion groups with single/multiple expand support
9
+ *
10
+ * Features:
11
+ * - Standalone collapse toggles (button controls panel via selector)
12
+ * - Accordion groups (single/multiple expand)
13
+ * - Expand/Collapse all functionality
14
+ * - Keyboard navigation (Arrow keys, Home, End)
15
+ * - Full ARIA support
16
+ * - Dynamic text for show/hide states
17
+ */
18
+
19
+ class AccessibleAccordion {
20
+ constructor() {
21
+ this.accordions = [];
22
+ this.init();
23
+ }
24
+
25
+ init() {
26
+ // Initialize standalone toggles (button with selector reference)
27
+ this.initStandaloneToggles();
28
+
29
+ // Initialize accordion groups (grouped items)
30
+ this.initAccordionGroups();
31
+ }
32
+
33
+ // Standalone collapse/expand (Button + Panel(s) via CSS selector)
34
+ initStandaloneToggles() {
35
+ const toggleButtons = document.querySelectorAll('[data-collapse-toggle]');
36
+
37
+ toggleButtons.forEach((button, buttonIndex) => {
38
+ const selector = button.getAttribute('data-collapse-toggle');
39
+ const panels = document.querySelectorAll(selector);
40
+
41
+ if (panels.length === 0) {
42
+ console.warn(`No panels found for selector "${selector}"`);
43
+ return;
44
+ }
45
+
46
+ // Setup ARIA
47
+ if (!button.id) button.id = `toggle-${buttonIndex}`;
48
+
49
+ // Collect panel IDs for aria-controls
50
+ const panelIds = [];
51
+ panels.forEach((panel, panelIndex) => {
52
+ if (!panel.id) {
53
+ panel.id = `panel-${buttonIndex}-${panelIndex}`;
54
+ }
55
+ panelIds.push(panel.id);
56
+ panel.setAttribute('aria-labelledby', button.id);
57
+ // Mark as collapse panel for CSS styling
58
+ panel.setAttribute('data-collapse-panel', '');
59
+ this.setPanelVisibility(panel, true);
60
+ });
61
+
62
+ // Set aria-controls with all panel IDs (space-separated)
63
+ button.setAttribute('aria-controls', panelIds.join(' '));
64
+ button.setAttribute('aria-expanded', 'false');
65
+
66
+ // Set initial aria-label based on collapsed state
67
+ this.updateButtonText(button, false);
68
+
69
+ // Event listener
70
+ button.addEventListener('click', () => {
71
+ this.toggleStandalone(button, panels);
72
+ });
73
+ });
74
+ }
75
+
76
+ toggleStandalone(button, panels) {
77
+ const isExpanded = button.getAttribute('aria-expanded') === 'true';
78
+ const newExpandedState = !isExpanded;
79
+
80
+ button.setAttribute('aria-expanded', newExpandedState);
81
+
82
+ // Toggle all matched panels
83
+ panels.forEach(panel => {
84
+ this.setPanelVisibility(panel, isExpanded);
85
+
86
+ // Update aria-expanded on associated accordion triggers (if panel is part of accordion)
87
+ const triggerId = panel.getAttribute('aria-labelledby');
88
+ if (triggerId) {
89
+ const trigger = document.getElementById(triggerId);
90
+ if (trigger) {
91
+ trigger.setAttribute('aria-expanded', newExpandedState);
92
+ this.updateButtonText(trigger, newExpandedState);
93
+ this.updateToggleIcon(trigger, newExpandedState);
94
+ }
95
+ }
96
+ });
97
+
98
+ // Update button text and aria-label based on new state
99
+ this.updateButtonText(button, newExpandedState);
100
+ }
101
+
102
+ // Accordion groups (multiple items in a group)
103
+ initAccordionGroups() {
104
+ const accordionGroups = document.querySelectorAll('[data-accordion]');
105
+
106
+ accordionGroups.forEach((group, groupIndex) => {
107
+ const allowMultiple = group.hasAttribute('data-accordion-multiple');
108
+ const items = Array.from(group.querySelectorAll('[data-accordion-item]'));
109
+
110
+ if (items.length === 0) return;
111
+
112
+ const accordion = {
113
+ element: group,
114
+ allowMultiple,
115
+ items: [],
116
+ toggleButtons: []
117
+ };
118
+
119
+ // Setup expand/collapse all buttons
120
+ this.setupGroupControls(group, groupIndex, accordion);
121
+
122
+ // Setup each accordion item
123
+ items.forEach((item, itemIndex) => {
124
+ const trigger = item.querySelector('[data-accordion-trigger]');
125
+ const panel = item.querySelector('[data-accordion-panel]');
126
+
127
+ if (!trigger || !panel) {
128
+ console.warn('Accordion item missing trigger or panel');
129
+ return;
130
+ }
131
+
132
+ // Setup IDs and ARIA
133
+ const triggerId = trigger.id || `accordion-${groupIndex}-trigger-${itemIndex}`;
134
+ const panelId = panel.id || `accordion-${groupIndex}-panel-${itemIndex}`;
135
+
136
+ trigger.id = triggerId;
137
+ panel.id = panelId;
138
+ trigger.setAttribute('aria-controls', panelId);
139
+ trigger.setAttribute('aria-expanded', 'false');
140
+ panel.setAttribute('aria-labelledby', triggerId);
141
+ panel.setAttribute('role', 'region');
142
+ this.setPanelVisibility(panel, true);
143
+
144
+ // Set initial aria-label based on collapsed state
145
+ this.updateButtonText(trigger, false);
146
+
147
+ // Store reference
148
+ accordion.items.push({ trigger, panel, item });
149
+
150
+ // Click event
151
+ trigger.addEventListener('click', () => {
152
+ this.toggleAccordionItem(accordion, itemIndex);
153
+ });
154
+
155
+ // Keyboard navigation
156
+ trigger.addEventListener('keydown', (e) => {
157
+ this.handleKeyboard(e, accordion, itemIndex);
158
+ });
159
+ });
160
+
161
+ // Setup aria-controls for toggle buttons (after all items are added)
162
+ this.setupToggleButtonsAriaControls(accordion);
163
+
164
+ this.accordions.push(accordion);
165
+ });
166
+ }
167
+
168
+ setupToggleButtonsAriaControls(accordion) {
169
+ // Collect all panel IDs
170
+ const panelIds = accordion.items.map(({ panel }) => panel.id);
171
+
172
+ // Set aria-controls on all toggle buttons
173
+ accordion.toggleButtons.forEach(toggleBtn => {
174
+ toggleBtn.setAttribute('aria-controls', panelIds.join(' '));
175
+ });
176
+ }
177
+
178
+ setupGroupControls(group, groupIndex, accordion) {
179
+ // Check for expand/collapse all container
180
+ const controlsTop = group.querySelector('[data-accordion-controls="top"]');
181
+ const controlsBottom = group.querySelector('[data-accordion-controls="bottom"]');
182
+
183
+ [controlsTop, controlsBottom].forEach(controls => {
184
+ if (!controls) return;
185
+
186
+ // Create toggle button if it doesn't exist
187
+ let toggleBtn = controls.querySelector('[data-accordion-toggle-all]');
188
+
189
+ if (!toggleBtn) {
190
+ toggleBtn = this.createToggleAllButton(groupIndex);
191
+ controls.appendChild(toggleBtn);
192
+ }
193
+
194
+ // Store reference to toggle button
195
+ accordion.toggleButtons.push(toggleBtn);
196
+
197
+ // Set initial state
198
+ toggleBtn.setAttribute('aria-expanded', 'false');
199
+ this.updateButtonText(toggleBtn, false);
200
+
201
+ // Add event listener
202
+ toggleBtn.addEventListener('click', () => {
203
+ this.toggleAll(groupIndex);
204
+ });
205
+ });
206
+ }
207
+
208
+ createToggleAllButton(groupIndex) {
209
+ const button = document.createElement('button');
210
+ button.type = 'button';
211
+ button.setAttribute('data-accordion-toggle-all', '');
212
+
213
+ // Set data attributes for dynamic text and aria-label
214
+ button.setAttribute('data-text-for-show', 'Otvoriť všetko');
215
+ button.setAttribute('data-text-for-hide', 'Zatvoriť všetko');
216
+ button.setAttribute('data-aria-label-for-show', 'Otvoriť všetky panely');
217
+ button.setAttribute('data-aria-label-for-hide', 'Zatvoriť všetky panely');
218
+
219
+ button.innerHTML = `
220
+ <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="none">
221
+ <path class="toggle-icon-expand" d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
222
+ <path class="toggle-icon-collapse" d="M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="display: none;"/>
223
+ </svg>
224
+ <span>Otvoriť všetko</span>
225
+ `;
226
+
227
+ return button;
228
+ }
229
+
230
+ toggleAccordionItem(accordion, index) {
231
+ const { trigger, panel } = accordion.items[index];
232
+ const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
233
+ const newExpandedState = !isExpanded;
234
+
235
+ if (!accordion.allowMultiple && !isExpanded) {
236
+ // Close all other panels
237
+ accordion.items.forEach((item, i) => {
238
+ if (i !== index) {
239
+ item.trigger.setAttribute('aria-expanded', 'false');
240
+ this.setPanelVisibility(item.panel, true);
241
+ this.updateButtonText(item.trigger, false);
242
+ this.updateToggleIcon(item.trigger, false);
243
+ }
244
+ });
245
+ }
246
+
247
+ trigger.setAttribute('aria-expanded', newExpandedState);
248
+ this.setPanelVisibility(panel, isExpanded);
249
+
250
+ // Update button text and aria-label based on new state
251
+ this.updateButtonText(trigger, newExpandedState);
252
+ this.updateToggleIcon(trigger, newExpandedState);
253
+
254
+ // Sync toggle all buttons state based on current accordion state
255
+ this.syncToggleAllButtons(accordion);
256
+ }
257
+
258
+ handleKeyboard(e, accordion, index) {
259
+ const { key } = e;
260
+ const triggers = accordion.items.map(item => item.trigger);
261
+ let targetIndex = index;
262
+
263
+ switch (key) {
264
+ case 'ArrowDown':
265
+ e.preventDefault();
266
+ targetIndex = index === triggers.length - 1 ? 0 : index + 1;
267
+ triggers[targetIndex].focus();
268
+ break;
269
+ case 'ArrowUp':
270
+ e.preventDefault();
271
+ targetIndex = index === 0 ? triggers.length - 1 : index - 1;
272
+ triggers[targetIndex].focus();
273
+ break;
274
+ case 'Home':
275
+ e.preventDefault();
276
+ triggers[0].focus();
277
+ break;
278
+ case 'End':
279
+ e.preventDefault();
280
+ triggers[triggers.length - 1].focus();
281
+ break;
282
+ }
283
+ }
284
+
285
+ toggleAll(groupIndex) {
286
+ const accordion = this.accordions[groupIndex];
287
+ if (!accordion) return;
288
+
289
+ // Get current state from first toggle button
290
+ const firstToggleBtn = accordion.toggleButtons[0];
291
+ if (!firstToggleBtn) return;
292
+
293
+ const isExpanded = firstToggleBtn.getAttribute('aria-expanded') === 'true';
294
+ const newExpandedState = !isExpanded;
295
+
296
+ // Update all accordion items
297
+ accordion.items.forEach(({ trigger, panel }) => {
298
+ trigger.setAttribute('aria-expanded', newExpandedState);
299
+ this.setPanelVisibility(panel, isExpanded);
300
+ this.updateButtonText(trigger, newExpandedState);
301
+ this.updateToggleIcon(trigger, newExpandedState);
302
+ });
303
+
304
+ // Update ALL toggle buttons (top and bottom)
305
+ accordion.toggleButtons.forEach(toggleButton => {
306
+ toggleButton.setAttribute('aria-expanded', newExpandedState);
307
+ this.updateButtonText(toggleButton, newExpandedState);
308
+ this.updateToggleIcon(toggleButton, newExpandedState);
309
+ });
310
+ }
311
+
312
+ syncToggleAllButtons(accordion) {
313
+ // Check if all items are expanded
314
+ const allExpanded = accordion.items.every(({ trigger }) =>
315
+ trigger.getAttribute('aria-expanded') === 'true'
316
+ );
317
+
318
+ // Check if all items are collapsed
319
+ const allCollapsed = accordion.items.every(({ trigger }) =>
320
+ trigger.getAttribute('aria-expanded') === 'false'
321
+ );
322
+
323
+ // Only update if all items are in the same state
324
+ if (allExpanded || allCollapsed) {
325
+ const newState = allExpanded;
326
+
327
+ // Update accordion's toggle all buttons
328
+ if (accordion.toggleButtons && accordion.toggleButtons.length > 0) {
329
+ accordion.toggleButtons.forEach(toggleButton => {
330
+ toggleButton.setAttribute('aria-expanded', newState);
331
+ this.updateButtonText(toggleButton, newState);
332
+ this.updateToggleIcon(toggleButton, newState);
333
+ });
334
+ }
335
+
336
+ // Find and update standalone collapse buttons that control these panels
337
+ const panelIds = accordion.items.map(({ panel }) => panel.id);
338
+ const standaloneButtons = document.querySelectorAll('[data-collapse-toggle]');
339
+
340
+ standaloneButtons.forEach(button => {
341
+ const ariaControls = button.getAttribute('aria-controls');
342
+ if (!ariaControls) return;
343
+
344
+ // Check if this button controls any of the accordion's panels
345
+ const controlledPanelIds = ariaControls.split(' ');
346
+ const controlsSomePanels = controlledPanelIds.some(id => panelIds.includes(id));
347
+
348
+ if (controlsSomePanels) {
349
+ button.setAttribute('aria-expanded', newState);
350
+ this.updateButtonText(button, newState);
351
+ this.updateToggleIcon(button, newState);
352
+ }
353
+ });
354
+ }
355
+ }
356
+
357
+ updateButtonText(button, isExpanded) {
358
+ const textForShow = button.getAttribute('data-text-for-show');
359
+ const textForHide = button.getAttribute('data-text-for-hide');
360
+ const ariaLabelForShow = button.getAttribute('data-aria-label-for-show');
361
+ const ariaLabelForHide = button.getAttribute('data-aria-label-for-hide');
362
+
363
+ if (textForShow && textForHide) {
364
+ // Update text in <span> element
365
+ const spanElement = button.querySelector('span');
366
+ if (spanElement) {
367
+ const currentText = isExpanded ? textForHide : textForShow;
368
+ spanElement.textContent = currentText;
369
+ }
370
+
371
+ // Update aria-label (use separate aria-label attributes if available, otherwise use text attributes)
372
+ const currentAriaLabel = isExpanded
373
+ ? (ariaLabelForHide || textForHide)
374
+ : (ariaLabelForShow || textForShow);
375
+ button.setAttribute('aria-label', currentAriaLabel);
376
+ }
377
+ }
378
+
379
+ updateToggleIcon(button, isExpanded) {
380
+ const expandIcon = button.querySelector('.toggle-icon-expand');
381
+ const collapseIcon = button.querySelector('.toggle-icon-collapse');
382
+
383
+ if (expandIcon && collapseIcon) {
384
+ if (isExpanded) {
385
+ // Show collapse icon (minus)
386
+ expandIcon.style.display = 'none';
387
+ collapseIcon.style.display = 'block';
388
+ } else {
389
+ // Show expand icon (plus)
390
+ expandIcon.style.display = 'block';
391
+ collapseIcon.style.display = 'none';
392
+ }
393
+ }
394
+ }
395
+
396
+ // Helper method to set panel visibility (using aria-hidden to avoid Tailwind conflicts)
397
+ setPanelVisibility(panel, shouldHide) {
398
+ // Use aria-hidden instead of hidden attribute to avoid Tailwind's [hidden] { display: none !important; }
399
+ panel.setAttribute('aria-hidden', shouldHide);
400
+ }
401
+ }
402
+
403
+ // Standalone Collapse class (extends Accordion but only initializes collapse toggles)
404
+ class AccessibleCollapse extends AccessibleAccordion {
405
+ init() {
406
+ // Only initialize standalone toggles, not accordion groups
407
+ this.initStandaloneToggles();
408
+ }
409
+ }
410
+
411
+ // Auto-initialize standalone collapses only
412
+ function initCollapses() {
413
+ return new AccessibleCollapse();
414
+ }
415
+
416
+ // Auto-initialize accordions (both collapses and accordion groups)
417
+ function initAccordions() {
418
+ return new AccessibleAccordion();
419
+ }
420
+
421
+ // Initialize on DOM ready (only if not using module bundler)
422
+ if (typeof window !== 'undefined' && !window.a11yKitManualInit) {
423
+ if (document.readyState === 'loading') {
424
+ document.addEventListener('DOMContentLoaded', initAccordions);
425
+ } else {
426
+ initAccordions();
427
+ }
428
+ }
429
+
430
+ // Register in global namespace for CDN usage
431
+ if (typeof window !== 'undefined') {
432
+ window.a11yKit = window.a11yKit || {};
433
+ window.a11yKit.Collapse = AccessibleCollapse;
434
+ window.a11yKit.Accordion = AccessibleAccordion;
435
+ window.a11yKit.initCollapses = initCollapses;
436
+ window.a11yKit.initAccordions = initAccordions;
437
+ }
438
+
439
+ // ES6 exports with short aliases
440
+ export {
441
+ AccessibleCollapse,
442
+ AccessibleCollapse as Collapse,
443
+ AccessibleAccordion,
444
+ AccessibleAccordion as Accordion,
445
+ initCollapses,
446
+ initAccordions
447
+ };