import { PROGRESS_CHANGE_EVENT, type ProgressChangeDetail, type ProgressOptions, } from './progress.types'; const SELECTORS = { label: '[data-c42-progress-label]', } as const; /** * Headless progress controller. Manages the ARIA `progressbar` role and value * attributes, reflects state on `data-state`, and exposes the completion * percentage as a `--c42-progress-percent` custom property on the root so CSS * can size the indicator. It never applies visual styles itself. * * Determinate and indeterminate (loading) modes are both supported. * * Markup: * ```html *
*
*
*
* *
* ``` */ export class Progress { private readonly root: HTMLElement; private readonly labelEl: HTMLElement | null; private value: number; private min: number; private max: number; private indeterminate: boolean; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: ProgressOptions = {}) { this.root = root; this.labelEl = root.querySelector(SELECTORS.label); this.min = options.min ?? 0; this.max = options.max ?? 100; if (this.max <= this.min) { this.max = this.min + 1; } this.value = this.clamp(options.value ?? 0); this.indeterminate = options.indeterminate ?? false; this.root.setAttribute('role', 'progressbar'); if (options.label && !this.root.hasAttribute('aria-labelledby')) { this.root.setAttribute('aria-label', options.label); } this.render(); } private clamp(value: number): number { if (Number.isNaN(value)) { return this.min; } return Math.min(this.max, Math.max(this.min, value)); } private get percent(): number { return ((this.value - this.min) / (this.max - this.min)) * 100; } private render(): void { this.root.setAttribute('aria-valuemin', String(this.min)); this.root.setAttribute('aria-valuemax', String(this.max)); if (this.indeterminate) { this.root.removeAttribute('aria-valuenow'); this.root.dataset.state = 'indeterminate'; this.root.style.removeProperty('--c42-progress-percent'); if (this.labelEl) { this.labelEl.textContent = ''; } return; } const percent = Math.round(this.percent); this.root.setAttribute('aria-valuenow', String(this.value)); this.root.style.setProperty('--c42-progress-percent', `${percent}%`); this.root.dataset.state = this.value >= this.max ? 'complete' : 'loading'; if (this.labelEl) { this.labelEl.textContent = `${percent}%`; } } private emit(): void { const detail: ProgressChangeDetail = { value: this.value, min: this.min, max: this.max, percent: this.indeterminate ? 0 : Math.round(this.percent), indeterminate: this.indeterminate, }; this.root.dispatchEvent(new CustomEvent(PROGRESS_CHANGE_EVENT, { detail, bubbles: true })); } /** Set the current value (clamped to [min, max]). Exits indeterminate mode. */ setValue(value: number): void { const next = this.clamp(value); if (next === this.value && !this.indeterminate) { return; } this.value = next; this.indeterminate = false; this.render(); this.emit(); } /** Update the upper bound and re-clamp the value. */ setMax(max: number): void { this.max = max <= this.min ? this.min + 1 : max; this.value = this.clamp(this.value); this.render(); this.emit(); } /** Toggle the indeterminate (loading) state. */ setIndeterminate(indeterminate: boolean): void { if (indeterminate === this.indeterminate) { return; } this.indeterminate = indeterminate; this.render(); this.emit(); } getValue(): number { return this.value; } /** 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 = []; } }