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 = [];
}
}