// ============================================================================
// Stylescape | Accordion Manager
// ============================================================================
// Manages accordion-style collapsible elements with accessibility support.
// Supports data-ss-accordion attributes for declarative configuration.
// ============================================================================
/**
* Configuration options for AccordionManager
*/
export interface AccordionOptions {
/** Allow multiple panels to be open simultaneously */
allowMultiple?: boolean;
/** Initially expanded panel index(es) */
defaultOpen?: number | number[];
/** Animation duration in milliseconds */
animationDuration?: number;
/** CSS class for active header */
activeHeaderClass?: string;
/** CSS class for active content */
activeContentClass?: string;
/** Callback when panel opens */
onOpen?: (
header: HTMLElement,
content: HTMLElement,
index: number,
) => void;
/** Callback when panel closes */
onClose?: (
header: HTMLElement,
content: HTMLElement,
index: number,
) => void;
/** Selector for content panel relative to header */
contentSelector?: string;
}
interface AccordionItem {
header: HTMLElement;
content: HTMLElement;
isOpen: boolean;
}
/**
* Accessible accordion component with animation support.
*
* @example JavaScript
* ```typescript
* const accordion = new AccordionManager(".accordion", {
* allowMultiple: false,
* defaultOpen: 0,
* animationDuration: 300
* })
* ```
*
* @example HTML with data-ss
* ```html
*
*
*
*
* Content for section 1
*
*
*
* ```
*/
export class AccordionManager {
private container: HTMLElement | null;
private items: AccordionItem[] = [];
private options: Required;
constructor(
selectorOrElement: string | HTMLElement,
options: AccordionOptions = {},
) {
this.container =
typeof selectorOrElement === "string"
? document.querySelector(selectorOrElement)
: selectorOrElement;
this.options = {
allowMultiple: options.allowMultiple ?? false,
defaultOpen: options.defaultOpen ?? -1,
animationDuration: options.animationDuration ?? 300,
activeHeaderClass:
options.activeHeaderClass ?? "accordion-header--active",
activeContentClass:
options.activeContentClass ?? "accordion-content--active",
onOpen: options.onOpen ?? (() => {}),
onClose: options.onClose ?? (() => {}),
contentSelector:
options.contentSelector ?? "[data-ss-accordion-content]",
};
if (!this.container) {
console.warn("[Stylescape] AccordionManager container not found");
return;
}
this.init();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Open a panel by index
*/
public open(index: number): void {
if (index < 0 || index >= this.items.length) return;
// Close others if not allowing multiple
if (!this.options.allowMultiple) {
this.items.forEach((item, i) => {
if (i !== index && item.isOpen) {
this.closeItem(i);
}
});
}
this.openItem(index);
}
/**
* Close a panel by index
*/
public close(index: number): void {
if (index < 0 || index >= this.items.length) return;
this.closeItem(index);
}
/**
* Toggle a panel by index
*/
public toggle(index: number): void {
if (index < 0 || index >= this.items.length) return;
if (this.items[index].isOpen) {
this.close(index);
} else {
this.open(index);
}
}
/**
* Open all panels
*/
public openAll(): void {
this.items.forEach((_, i) => this.openItem(i));
}
/**
* Close all panels
*/
public closeAll(): void {
this.items.forEach((_, i) => this.closeItem(i));
}
/**
* Check if a panel is open
*/
public isOpen(index: number): boolean {
return this.items[index]?.isOpen ?? false;
}
/**
* Destroy the accordion
*/
public destroy(): void {
this.items.forEach((item) => {
item.header.removeEventListener("click", this.handleClick);
item.header.removeEventListener("keydown", this.handleKeydown);
});
this.items = [];
}
// ========================================================================
// Static Factory
// ========================================================================
/**
* Initialize all accordions with data-ss="accordion"
*/
public static initAccordions(): AccordionManager[] {
const managers: AccordionManager[] = [];
const accordions = document.querySelectorAll(
'[data-ss="accordion"]',
);
accordions.forEach((el) => {
const multiple = el.dataset.ssAccordionMultiple === "true";
const defaultOpen = el.dataset.ssAccordionDefaultOpen;
managers.push(
new AccordionManager(el, {
allowMultiple: multiple,
defaultOpen: defaultOpen ? parseInt(defaultOpen, 10) : -1,
}),
);
});
return managers;
}
// ========================================================================
// Private Methods
// ========================================================================
private init(): void {
if (!this.container) return;
// Find headers and contents
const headers = this.container.querySelectorAll(
"[data-ss-accordion-header], .accordion-header",
);
headers.forEach((header, index) => {
const content = this.findContent(header);
if (!content) return;
// Setup ARIA attributes
const id = `accordion-content-${index}-${Date.now()}`;
content.id = content.id || id;
header.setAttribute("aria-expanded", "false");
header.setAttribute("aria-controls", content.id);
content.setAttribute("role", "region");
content.setAttribute(
"aria-labelledby",
header.id || `accordion-header-${index}`,
);
// Ensure header is focusable
if (!header.hasAttribute("tabindex")) {
header.setAttribute("tabindex", "0");
}
// Hide content initially
content.hidden = true;
content.style.overflow = "hidden";
this.items.push({
header,
content,
isOpen: false,
});
// Add event listeners
header.addEventListener("click", this.handleClick);
header.addEventListener("keydown", this.handleKeydown);
});
// Open default panels
this.openDefaults();
}
private findContent(header: HTMLElement): HTMLElement | null {
// Try next sibling first
const nextSibling = header.nextElementSibling;
if (nextSibling?.matches(this.options.contentSelector)) {
return nextSibling as HTMLElement;
}
// Try within parent
const parent = header.parentElement;
return (
parent?.querySelector(this.options.contentSelector) ??
null
);
}
private openDefaults(): void {
const defaults = this.options.defaultOpen;
if (Array.isArray(defaults)) {
defaults.forEach((i) => this.openItem(i));
} else if (defaults >= 0) {
this.openItem(defaults);
}
}
private openItem(index: number): void {
const item = this.items[index];
if (!item || item.isOpen) return;
item.header.setAttribute("aria-expanded", "true");
item.header.classList.add(this.options.activeHeaderClass);
item.content.hidden = false;
item.content.classList.add(this.options.activeContentClass);
// Animate open
const height = item.content.scrollHeight;
item.content.style.height = "0px";
requestAnimationFrame(() => {
item.content.style.transition = `height ${this.options.animationDuration}ms ease`;
item.content.style.height = `${height}px`;
setTimeout(() => {
item.content.style.height = "";
item.content.style.transition = "";
}, this.options.animationDuration);
});
item.isOpen = true;
this.options.onOpen(item.header, item.content, index);
}
private closeItem(index: number): void {
const item = this.items[index];
if (!item || !item.isOpen) return;
item.header.setAttribute("aria-expanded", "false");
item.header.classList.remove(this.options.activeHeaderClass);
item.content.classList.remove(this.options.activeContentClass);
// Animate close
const height = item.content.scrollHeight;
item.content.style.height = `${height}px`;
requestAnimationFrame(() => {
item.content.style.transition = `height ${this.options.animationDuration}ms ease`;
item.content.style.height = "0px";
setTimeout(() => {
item.content.hidden = true;
item.content.style.height = "";
item.content.style.transition = "";
}, this.options.animationDuration);
});
item.isOpen = false;
this.options.onClose(item.header, item.content, index);
}
private handleClick = (event: Event): void => {
const header = event.currentTarget as HTMLElement;
const index = this.items.findIndex((item) => item.header === header);
if (index !== -1) {
this.toggle(index);
}
};
private handleKeydown = (event: KeyboardEvent): void => {
const header = event.currentTarget as HTMLElement;
const index = this.items.findIndex((item) => item.header === header);
switch (event.key) {
case "Enter":
case " ":
event.preventDefault();
this.toggle(index);
break;
case "ArrowDown":
event.preventDefault();
this.focusHeader((index + 1) % this.items.length);
break;
case "ArrowUp":
event.preventDefault();
this.focusHeader(
(index - 1 + this.items.length) % this.items.length,
);
break;
case "Home":
event.preventDefault();
this.focusHeader(0);
break;
case "End":
event.preventDefault();
this.focusHeader(this.items.length - 1);
break;
}
};
private focusHeader(index: number): void {
this.items[index]?.header.focus();
}
}
export default AccordionManager;