Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

starter-remix/app/ui/busy.ts

72 lines · 2.0 KB · TypeScript
/**
 * 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<typeof setTimeout> | 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'
		},
	}
}