import { SKELETON_CHANGE_EVENT, type SkeletonChangeDetail, type SkeletonOptions, } from './skeleton.types'; const SELECTORS = { placeholder: '[data-c42-skeleton-placeholder]', content: '[data-c42-skeleton-content]', } as const; /** * Headless skeleton controller. Orchestrates the loading→content swap on * existing markup by toggling `[hidden]` on the placeholder/content and * reflecting state on the region; it never applies visual styles. * * The individual `[data-c42-skeleton]` shapes are usable standalone via CSS * (no controller needed) — each keeps its `data-variant` (text|circle|rect). * * Markup: * ```html *
*
* * *
* *
* ``` */ export class Skeleton { private readonly root: HTMLElement; private readonly placeholder: HTMLElement; private readonly content: HTMLElement | null; private isLoading = true; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: SkeletonOptions = {}) { const placeholder = root.querySelector(SELECTORS.placeholder); if (!placeholder) { throw new Error('[42/skeleton] Needs a [data-c42-skeleton-placeholder] element.'); } this.root = root; this.placeholder = placeholder; this.content = root.querySelector(SELECTORS.content); this.isLoading = options.loading ?? true; this.render(); } private render(): void { this.root.dataset.loading = String(this.isLoading); this.root.dataset.state = this.isLoading ? 'loading' : 'ready'; this.root.setAttribute('aria-busy', String(this.isLoading)); this.placeholder.toggleAttribute('hidden', !this.isLoading); if (this.content) { this.content.toggleAttribute('hidden', this.isLoading); } } private emit(): void { const detail: SkeletonChangeDetail = { loading: this.isLoading }; this.root.dispatchEvent(new CustomEvent(SKELETON_CHANGE_EVENT, { detail, bubbles: true })); } /** Toggle the loading state. No-op if already in the requested state. */ setLoading(loading: boolean): void { if (loading === this.isLoading) { return; } this.isLoading = loading; this.render(); this.emit(); } get loading(): boolean { return this.isLoading; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }