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