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