317 lines
7.6 KiB
JavaScript
317 lines
7.6 KiB
JavaScript
export class AccordionComponent {
|
|
constructor(element, options = {}) {
|
|
this.element = element;
|
|
this.options = {
|
|
allowMultiple: false,
|
|
animationDuration: 300,
|
|
autoClose: true,
|
|
keyboardNavigation: true,
|
|
...options
|
|
};
|
|
|
|
this.items = [];
|
|
this.activeItems = new Set();
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.bindElements();
|
|
this.bindEvents();
|
|
this.setInitialState();
|
|
this.setupAccessibility();
|
|
}
|
|
|
|
bindElements() {
|
|
const itemElements = this.element.querySelectorAll('.accordion__item');
|
|
|
|
this.items = Array.from(itemElements).map(item => {
|
|
const trigger = item.querySelector('.accordion__header');
|
|
const content = item.querySelector('.accordion__content');
|
|
const id = trigger?.dataset.accordionTrigger || this.generateId();
|
|
|
|
return {
|
|
element: item,
|
|
trigger,
|
|
content,
|
|
id,
|
|
isOpen: false
|
|
};
|
|
});
|
|
|
|
if (this.items.length === 0) {
|
|
console.warn('AccordionComponent: No accordion items found');
|
|
return;
|
|
}
|
|
}
|
|
|
|
bindEvents() {
|
|
this.items.forEach((item, index) => {
|
|
if (!item.trigger || !item.content) return;
|
|
|
|
// Click events
|
|
item.trigger.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
this.toggle(index);
|
|
});
|
|
|
|
// Keyboard events
|
|
if (this.options.keyboardNavigation) {
|
|
item.trigger.addEventListener('keydown', (e) => {
|
|
this.handleKeyboard(e, index);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
setInitialState() {
|
|
this.items.forEach((item, index) => {
|
|
const isInitiallyOpen = item.trigger?.getAttribute('aria-expanded') === 'true';
|
|
|
|
if (isInitiallyOpen) {
|
|
this.open(index, false); // Open without animation initially
|
|
} else {
|
|
this.close(index, false);
|
|
}
|
|
});
|
|
}
|
|
|
|
setupAccessibility() {
|
|
this.items.forEach((item, index) => {
|
|
if (!item.trigger || !item.content) return;
|
|
|
|
const triggerId = `accordion-trigger-${item.id}`;
|
|
const contentId = `accordion-content-${item.id}`;
|
|
|
|
// Set up ARIA attributes
|
|
item.trigger.setAttribute('id', triggerId);
|
|
item.trigger.setAttribute('aria-controls', contentId);
|
|
item.trigger.setAttribute('tabindex', '0');
|
|
|
|
item.content.setAttribute('id', contentId);
|
|
item.content.setAttribute('aria-labelledby', triggerId);
|
|
item.content.setAttribute('role', 'region');
|
|
});
|
|
}
|
|
|
|
toggle(index) {
|
|
const item = this.items[index];
|
|
if (!item) return;
|
|
|
|
if (item.isOpen) {
|
|
this.close(index);
|
|
} else {
|
|
this.open(index);
|
|
}
|
|
}
|
|
|
|
open(index, animate = true) {
|
|
const item = this.items[index];
|
|
if (!item || item.isOpen) return;
|
|
|
|
// Close other items if multiple is not allowed
|
|
if (!this.options.allowMultiple && this.options.autoClose) {
|
|
this.closeAll(index);
|
|
}
|
|
|
|
item.isOpen = true;
|
|
this.activeItems.add(index);
|
|
|
|
// Update ARIA attributes
|
|
item.trigger.setAttribute('aria-expanded', 'true');
|
|
item.content.setAttribute('aria-hidden', 'false');
|
|
|
|
// Add active class
|
|
item.element.classList.add('accordion__item--active');
|
|
|
|
if (animate) {
|
|
this.animateOpen(item);
|
|
} else {
|
|
item.content.style.maxHeight = 'none';
|
|
}
|
|
|
|
// Emit custom event
|
|
this.element.dispatchEvent(new CustomEvent('accordionopen', {
|
|
detail: { index, item }
|
|
}));
|
|
}
|
|
|
|
close(index, animate = true) {
|
|
const item = this.items[index];
|
|
if (!item || !item.isOpen) return;
|
|
|
|
item.isOpen = false;
|
|
this.activeItems.delete(index);
|
|
|
|
// Update ARIA attributes
|
|
item.trigger.setAttribute('aria-expanded', 'false');
|
|
item.content.setAttribute('aria-hidden', 'true');
|
|
|
|
// Remove active class
|
|
item.element.classList.remove('accordion__item--active');
|
|
|
|
if (animate) {
|
|
this.animateClose(item);
|
|
} else {
|
|
item.content.style.maxHeight = '0';
|
|
}
|
|
|
|
// Emit custom event
|
|
this.element.dispatchEvent(new CustomEvent('accordionclose', {
|
|
detail: { index, item }
|
|
}));
|
|
}
|
|
|
|
closeAll(except = -1) {
|
|
this.items.forEach((item, index) => {
|
|
if (index !== except && item.isOpen) {
|
|
this.close(index);
|
|
}
|
|
});
|
|
}
|
|
|
|
openAll() {
|
|
if (!this.options.allowMultiple) return;
|
|
|
|
this.items.forEach((item, index) => {
|
|
if (!item.isOpen) {
|
|
this.open(index);
|
|
}
|
|
});
|
|
}
|
|
|
|
animateOpen(item) {
|
|
const content = item.content;
|
|
const scrollHeight = content.scrollHeight;
|
|
|
|
// Set initial state
|
|
content.style.maxHeight = '0';
|
|
content.style.overflow = 'hidden';
|
|
|
|
// Force reflow
|
|
content.offsetHeight;
|
|
|
|
// Add transition
|
|
content.style.transition = `max-height ${this.options.animationDuration}ms ease-out`;
|
|
|
|
// Animate to full height
|
|
content.style.maxHeight = `${scrollHeight}px`;
|
|
|
|
// Clean up after animation
|
|
setTimeout(() => {
|
|
content.style.maxHeight = 'none';
|
|
content.style.overflow = '';
|
|
content.style.transition = '';
|
|
}, this.options.animationDuration);
|
|
}
|
|
|
|
animateClose(item) {
|
|
const content = item.content;
|
|
const scrollHeight = content.scrollHeight;
|
|
|
|
// Set initial state
|
|
content.style.maxHeight = `${scrollHeight}px`;
|
|
content.style.overflow = 'hidden';
|
|
|
|
// Force reflow
|
|
content.offsetHeight;
|
|
|
|
// Add transition
|
|
content.style.transition = `max-height ${this.options.animationDuration}ms ease-in`;
|
|
|
|
// Animate to zero height
|
|
content.style.maxHeight = '0';
|
|
|
|
// Clean up after animation
|
|
setTimeout(() => {
|
|
content.style.overflow = '';
|
|
content.style.transition = '';
|
|
}, this.options.animationDuration);
|
|
}
|
|
|
|
handleKeyboard(e, index) {
|
|
const currentItem = this.items[index];
|
|
if (!currentItem) return;
|
|
|
|
switch (e.key) {
|
|
case 'Enter':
|
|
case ' ':
|
|
e.preventDefault();
|
|
this.toggle(index);
|
|
break;
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
this.focusNext(index);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
this.focusPrev(index);
|
|
break;
|
|
case 'Home':
|
|
e.preventDefault();
|
|
this.focusFirst();
|
|
break;
|
|
case 'End':
|
|
e.preventDefault();
|
|
this.focusLast();
|
|
break;
|
|
}
|
|
}
|
|
|
|
focusNext(currentIndex) {
|
|
const nextIndex = currentIndex < this.items.length - 1 ?
|
|
currentIndex + 1 : 0;
|
|
this.items[nextIndex]?.trigger?.focus();
|
|
}
|
|
|
|
focusPrev(currentIndex) {
|
|
const prevIndex = currentIndex > 0 ?
|
|
currentIndex - 1 : this.items.length - 1;
|
|
this.items[prevIndex]?.trigger?.focus();
|
|
}
|
|
|
|
focusFirst() {
|
|
this.items[0]?.trigger?.focus();
|
|
}
|
|
|
|
focusLast() {
|
|
this.items[this.items.length - 1]?.trigger?.focus();
|
|
}
|
|
|
|
generateId() {
|
|
return `accordion-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
// Public API
|
|
getActiveItems() {
|
|
return Array.from(this.activeItems);
|
|
}
|
|
|
|
isOpen(index) {
|
|
return this.items[index]?.isOpen || false;
|
|
}
|
|
|
|
destroy() {
|
|
// Remove event listeners and reset state
|
|
this.items.forEach(item => {
|
|
if (item.trigger) {
|
|
item.trigger.removeAttribute('aria-expanded');
|
|
item.trigger.removeAttribute('aria-controls');
|
|
item.trigger.removeAttribute('tabindex');
|
|
}
|
|
|
|
if (item.content) {
|
|
item.content.removeAttribute('aria-hidden');
|
|
item.content.removeAttribute('aria-labelledby');
|
|
item.content.removeAttribute('role');
|
|
item.content.style.maxHeight = '';
|
|
item.content.style.overflow = '';
|
|
item.content.style.transition = '';
|
|
}
|
|
|
|
item.element.classList.remove('accordion__item--active');
|
|
});
|
|
|
|
this.activeItems.clear();
|
|
}
|
|
} |