// ============================================================================
// Stylescape | Collapsible Section Manager
// ============================================================================
// Manages collapsible sections with smooth animations and accessibility.
// Supports data-ss-collapsible attributes for declarative configuration.
// ============================================================================
/**
* Configuration options for CollapsibleSectionManager
*/
export interface CollapsibleSectionOptions {
/** Initially expanded state */
expanded?: boolean;
/** Animation duration in milliseconds */
animationDuration?: number;
/** CSS class for collapsed state */
collapsedClass?: string;
/** CSS class for expanded state */
expandedClass?: string;
/** Persist state in localStorage */
persist?: boolean;
/** Storage key for persistence */
storageKey?: string;
/** Callback when section expands */
onExpand?: (element: HTMLElement) => void;
/** Callback when section collapses */
onCollapse?: (element: HTMLElement) => void;
/** Selector for the trigger element */
triggerSelector?: string;
/** Selector for the content element */
contentSelector?: string;
}
/**
* Collapsible section with animation and state persistence.
*
* @example JavaScript
* ```typescript
* const section = new CollapsibleSectionManager("#faq-item-1", {
* expanded: false,
* persist: true,
* onExpand: () => trackAnalytics("faq_expanded")
* })
* ```
*
* @example HTML with data-ss
* ```html
*
*
*
* Collapsible content here
*
*
* ```
*/
export class CollapsibleSectionManager {
private element: HTMLElement | null;
private trigger: HTMLElement | null = null;
private content: HTMLElement | null = null;
private options: Required;
private isExpanded: boolean = false;
constructor(
selectorOrElement: string | HTMLElement,
options: CollapsibleSectionOptions = {},
) {
this.element =
typeof selectorOrElement === "string"
? document.querySelector(selectorOrElement)
: selectorOrElement;
this.options = {
expanded: options.expanded ?? false,
animationDuration: options.animationDuration ?? 300,
collapsedClass: options.collapsedClass ?? "collapsible--collapsed",
expandedClass: options.expandedClass ?? "collapsible--expanded",
persist: options.persist ?? false,
storageKey:
options.storageKey ?? this.element?.id ?? "collapsible-state",
onExpand: options.onExpand ?? (() => {}),
onCollapse: options.onCollapse ?? (() => {}),
triggerSelector:
options.triggerSelector ?? "[data-ss-collapsible-trigger]",
contentSelector:
options.contentSelector ?? "[data-ss-collapsible-content]",
};
if (!this.element) {
console.warn(
"[Stylescape] CollapsibleSectionManager element not found",
);
return;
}
this.init();
}
// ========================================================================
// Public Properties
// ========================================================================
/**
* Get expanded state
*/
public get expanded(): boolean {
return this.isExpanded;
}
/**
* Set expanded state
*/
public set expanded(value: boolean) {
if (value) {
this.expand();
} else {
this.collapse();
}
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Toggle expanded state
*/
public toggle(): void {
if (this.isExpanded) {
this.collapse();
} else {
this.expand();
}
}
/**
* Expand the section
*/
public expand(): void {
if (!this.element || !this.content || this.isExpanded) return;
this.isExpanded = true;
// Update classes
this.element.classList.remove(this.options.collapsedClass);
this.element.classList.add(this.options.expandedClass);
// Update ARIA
this.trigger?.setAttribute("aria-expanded", "true");
this.content.hidden = false;
// Animate
const height = this.content.scrollHeight;
this.content.style.height = "0px";
this.content.style.overflow = "hidden";
requestAnimationFrame(() => {
if (!this.content) return;
this.content.style.transition = `height ${this.options.animationDuration}ms ease`;
this.content.style.height = `${height}px`;
setTimeout(() => {
if (!this.content) return;
this.content.style.height = "";
this.content.style.overflow = "";
this.content.style.transition = "";
}, this.options.animationDuration);
});
// Persist state
if (this.options.persist) {
localStorage.setItem(this.options.storageKey, "true");
}
this.options.onExpand(this.element);
}
/**
* Collapse the section
*/
public collapse(): void {
if (!this.element || !this.content || !this.isExpanded) return;
this.isExpanded = false;
// Update classes
this.element.classList.add(this.options.collapsedClass);
this.element.classList.remove(this.options.expandedClass);
// Update ARIA
this.trigger?.setAttribute("aria-expanded", "false");
// Animate
const height = this.content.scrollHeight;
this.content.style.height = `${height}px`;
this.content.style.overflow = "hidden";
requestAnimationFrame(() => {
if (!this.content) return;
this.content.style.transition = `height ${this.options.animationDuration}ms ease`;
this.content.style.height = "0px";
setTimeout(() => {
if (!this.content) return;
this.content.hidden = true;
this.content.style.height = "";
this.content.style.overflow = "";
this.content.style.transition = "";
}, this.options.animationDuration);
});
// Persist state
if (this.options.persist) {
localStorage.setItem(this.options.storageKey, "false");
}
this.options.onCollapse(this.element);
}
/**
* Destroy the manager
*/
public destroy(): void {
this.trigger?.removeEventListener("click", this.handleClick);
this.trigger?.removeEventListener("keydown", this.handleKeydown);
this.element = null;
this.trigger = null;
this.content = null;
}
// ========================================================================
// Static Factory
// ========================================================================
/**
* Initialize all collapsible sections with data-ss="collapsible"
*/
public static initCollapsibles(): CollapsibleSectionManager[] {
const managers: CollapsibleSectionManager[] = [];
document
.querySelectorAll('[data-ss="collapsible"]')
.forEach((el) => {
const expanded = el.dataset.ssCollapsibleExpanded === "true";
const persist = el.dataset.ssCollapsiblePersist === "true";
managers.push(
new CollapsibleSectionManager(el, {
expanded,
persist,
}),
);
});
return managers;
}
// ========================================================================
// Private Methods
// ========================================================================
private init(): void {
if (!this.element) return;
// Find trigger and content
this.trigger =
this.element.querySelector(
this.options.triggerSelector,
) ||
this.element.querySelector(".collapsible-trigger") ||
this.element;
this.content =
this.element.querySelector(
this.options.contentSelector,
) ||
this.element.querySelector(".collapsible-content");
if (!this.content) {
// If no specific content, use the element itself
this.content = this.element;
}
// Setup ARIA
const contentId =
this.content.id || `collapsible-content-${Date.now()}`;
this.content.id = contentId;
this.trigger.setAttribute("aria-controls", contentId);
// Make trigger focusable
if (
!this.trigger.hasAttribute("tabindex") &&
this.trigger.tagName !== "BUTTON"
) {
this.trigger.setAttribute("tabindex", "0");
}
// Load persisted state
let initialExpanded = this.options.expanded;
if (this.options.persist) {
const stored = localStorage.getItem(this.options.storageKey);
if (stored !== null) {
initialExpanded = stored === "true";
}
}
// Set initial state
this.isExpanded = !initialExpanded; // Toggle will flip this
this.toggle();
// Add event listeners
this.trigger.addEventListener("click", this.handleClick);
this.trigger.addEventListener("keydown", this.handleKeydown);
}
private handleClick = (event: Event): void => {
// Don't toggle if clicking inside content
if (
this.content?.contains(event.target as Node) &&
event.target !== this.trigger
) {
return;
}
this.toggle();
};
private handleKeydown = (event: KeyboardEvent): void => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.toggle();
}
};
}
export default CollapsibleSectionManager;