/** * tapconfirm holds the small state machine behind the "tap to confirm" controls: the * first tap arms a confirmation window of durationMs (during which the view shows a * fading ✅), a second tap within it confirms, and otherwise the window reverts. It is * framework agnostic — a view observes onChange and renders accordingly — so the timing * logic is unit-testable without a DOM. The pending timer is the only side effect. */ export interface TapConfirmOptions { /** Length of the confirmation window in milliseconds. */ durationMs: number; /** Invoked once when a confirmation lands inside the window. */ onConfirm: () => void; /** Invoked whenever the confirming flag flips, so a view can react. */ onChange?: (confirming: boolean) => void; } /** TapConfirmController drives a single "tap to confirm" control. */ export interface TapConfirmController { /** Whether the confirmation window is currently open. */ readonly confirming: boolean; /** Arm the confirmation window; a no-op while it is already open. */ arm(): void; /** Confirm within the window: fires onConfirm once and closes the window. A no-op * while the window is closed. */ confirm(): void; /** Close the window without confirming (e.g. the control was disabled). */ cancel(): void; /** Clear any pending timer; the controller must not be reused afterwards. */ dispose(): void; } /** * createTapConfirm builds a TapConfirmController whose confirmation window lasts * durationMs. onConfirm fires once per confirmed window; onChange (when given) * reports every flip of the confirming flag. */ export function createTapConfirm(opts: TapConfirmOptions): TapConfirmController { let confirming = false; let timer: ReturnType | null = null; function clear(): void { if (timer !== null) { clearTimeout(timer); timer = null; } } function set(next: boolean): void { if (confirming === next) return; confirming = next; opts.onChange?.(next); } return { get confirming() { return confirming; }, arm() { if (confirming) return; set(true); timer = setTimeout(() => { timer = null; set(false); }, opts.durationMs); }, confirm() { if (!confirming) return; clear(); set(false); opts.onConfirm(); }, cancel() { if (!confirming) return; clear(); set(false); }, dispose() { clear(); }, }; }