import { STEPPER_CHANGE_EVENT, type StepperChangeDetail, type StepperOptions } from './stepper.types';
interface Step {
value: string;
step: HTMLElement;
trigger: HTMLElement | null;
indicator: HTMLElement | null;
panel: HTMLElement | null;
}
const SELECTORS = {
step: '[data-c42-stepper-step]',
trigger: '[data-c42-stepper-trigger]',
indicator: '[data-c42-stepper-indicator]',
panel: '[data-c42-stepper-panel]',
} as const;
/**
* Headless stepper / wizard. Tracks an ordered set of steps and exposes
* completed/current/upcoming state via `data-state`, with linear or free
* navigation. Replaces the old Alpine status-indicator state machine.
*
* Markup:
* ```html
*
* ```
*/
export class Stepper {
private readonly root: HTMLElement;
private readonly linear: boolean;
private steps: Step[] = [];
private currentIndex = 0;
private readonly completed = new Set();
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: StepperOptions = {}) {
this.root = root;
this.linear = options.linear ?? true;
this.init(options.defaultValue ?? null);
}
private init(defaultValue: string | number | null): void {
const stepEls = Array.from(this.root.querySelectorAll(SELECTORS.step));
this.steps = stepEls.map((step, index) => ({
value: step.dataset.value ?? String(index),
step,
trigger: step.querySelector(SELECTORS.trigger),
indicator: step.querySelector(SELECTORS.indicator),
panel: step.querySelector(SELECTORS.panel),
}));
this.currentIndex = this.resolveIndex(defaultValue);
for (let i = 0; i < this.currentIndex; i += 1) {
this.completed.add(i);
}
this.steps.forEach((step, index) => {
if (step.trigger) {
const onClick = (): void => this.goTo(index);
step.trigger.addEventListener('click', onClick);
this.cleanups.push(() => step.trigger?.removeEventListener('click', onClick));
}
});
this.render();
}
private resolveIndex(value: string | number | null): number {
if (value === null) {
return 0;
}
if (typeof value === 'number') {
return Math.max(0, Math.min(value, this.steps.length - 1));
}
const found = this.steps.findIndex((step) => step.value === value);
return found === -1 ? 0 : found;
}
private isReachable(index: number): boolean {
return !this.linear || index === this.currentIndex || this.completed.has(index);
}
private stateOf(index: number): 'completed' | 'current' | 'upcoming' {
if (index === this.currentIndex) {
return 'current';
}
return this.completed.has(index) ? 'completed' : 'upcoming';
}
private render(): void {
this.steps.forEach((step, index) => {
const state = this.stateOf(index);
step.step.dataset.state = state;
step.indicator?.setAttribute('data-state', state);
if (step.trigger) {
step.trigger.dataset.state = state;
const reachable = this.isReachable(index);
step.trigger.setAttribute('aria-disabled', String(!reachable));
if (state === 'current') {
step.trigger.setAttribute('aria-current', 'step');
} else {
step.trigger.removeAttribute('aria-current');
}
}
if (step.panel) {
step.panel.toggleAttribute('hidden', index !== this.currentIndex);
}
});
}
private emit(): void {
const current = this.steps[this.currentIndex];
if (!current) {
return;
}
const detail: StepperChangeDetail = { value: current.value, index: this.currentIndex };
this.root.dispatchEvent(new CustomEvent(STEPPER_CHANGE_EVENT, { detail, bubbles: true }));
}
goTo(target: string | number): void {
const index = typeof target === 'number' ? target : this.steps.findIndex((s) => s.value === target);
if (index < 0 || index >= this.steps.length || index === this.currentIndex) {
return;
}
if (!this.isReachable(index)) {
return;
}
this.currentIndex = index;
this.render();
this.emit();
}
next(): void {
if (this.currentIndex >= this.steps.length - 1) {
this.completed.add(this.currentIndex);
this.render();
return;
}
this.completed.add(this.currentIndex);
this.currentIndex += 1;
this.render();
this.emit();
}
prev(): void {
if (this.currentIndex === 0) {
return;
}
this.currentIndex -= 1;
this.render();
this.emit();
}
get value(): string {
return this.steps[this.currentIndex]?.value ?? '';
}
get index(): number {
return this.currentIndex;
}
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 = [];
}
}