/** * Busy / pending helpers inspired by `spin-delay`: * - Give immediate affordance on press (so taps never feel ignored) * - Delay showing a "loading" label/spinner so fast ops do not flash * - Once shown, keep it visible for a minimum duration */ export type BusyPhase = 'idle' | 'pending' | 'busy' export type BusyGateOptions = { /** Wait this long before flipping pending → busy (default 200ms). */ delayMs?: number /** Once busy, stay busy at least this long (default 400ms). */ minDurationMs?: number } export function createBusyGate(options: BusyGateOptions = {}) { const delayMs = options.delayMs ?? 200 const minDurationMs = options.minDurationMs ?? 400 let phase: BusyPhase = 'idle' let delayTimer: ReturnType | null = null let busySince = 0 let generation = 0 function clearDelay() { if (delayTimer) { clearTimeout(delayTimer) delayTimer = null } } return { get phase() { return phase }, /** Call synchronously on pointer/click — marks pending immediately. */ start(onChange?: (phase: BusyPhase) => void) { generation += 1 const gen = generation clearDelay() phase = 'pending' onChange?.(phase) delayTimer = setTimeout(() => { if (gen !== generation) return phase = 'busy' busySince = Date.now() onChange?.(phase) }, delayMs) }, /** Resolve when the async work finishes; respects min busy duration. */ async stop(onChange?: (phase: BusyPhase) => void) { const gen = generation clearDelay() if (phase === 'busy') { const elapsed = Date.now() - busySince const wait = Math.max(0, minDurationMs - elapsed) if (wait > 0) await new Promise((r) => setTimeout(r, wait)) } if (gen !== generation) return phase = 'idle' onChange?.(phase) }, /** True while the user should see a loading treatment (after delay). */ get showBusy() { return phase === 'busy' }, /** True from the moment of press until stop — use for disable / aria-busy. */ get isActive() { return phase !== 'idle' }, } }