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.

src/double-check.ts

211 lines · 5.4 KB · TypeScript
/**
 * Vanilla two-step / type-to-confirm helpers for destructive actions.
 * Mirrors Epic Web `useDoubleCheck` and Kody platform confirm-name patterns,
 * without a React dependency (package apps are vanilla HTML/JS).
 */

export type DoubleCheckHandlers = {
	onClick?: (event: Event) => void
	onBlur?: (event: Event) => void
	onKeyUp?: (event: KeyboardEvent) => void
}

export type DoubleCheckController = {
	/** True after the first click arms the confirm; second click should run the action. */
	readonly doubleCheck: boolean
	setDoubleCheck: (value: boolean) => void
	reset: () => void
	subscribe: (listener: () => void) => () => void
	/**
	 * Mergeable button props. First click preventDefaults and arms;
	 * second click leaves the event alone so the caller's onClick / form submit runs.
	 */
	getButtonProps: (props?: DoubleCheckHandlers) => Required<DoubleCheckHandlers>
}

function callAll(
	...fns: Array<((event: never) => void) | undefined>
): (event: Event) => void {
	return (event) => {
		for (const fn of fns) {
			if (typeof fn === 'function') (fn as (event: Event) => void)(event)
		}
	}
}

/**
 * Create a two-step confirm controller (Epic Web `useDoubleCheck` for vanilla).
 * Use for delete / irreversible buttons so a single tap cannot destroy data.
 *
 * @returns Controller with `doubleCheck`, `getButtonProps`, `reset`, `subscribe`
 *
 * @example
 * import { createDoubleCheck } from 'kody:@kentcdodds/package-app-kit/double-check'
 * const dc = createDoubleCheck()
 * const props = dc.getButtonProps({
 *   onClick: (e) => {
 *     if (e.defaultPrevented) return // still arming
 *     void deleteItem()
 *   },
 * })
 * button.addEventListener('click', props.onClick)
 * button.textContent = dc.doubleCheck ? 'Are you sure?' : 'Delete'
 */
export function createDoubleCheck(): DoubleCheckController {
	let doubleCheck = false
	const listeners = new Set<() => void>()

	function notify() {
		for (const listener of listeners) listener()
	}

	function setDoubleCheck(value: boolean) {
		if (doubleCheck === value) return
		doubleCheck = value
		notify()
	}

	function getButtonProps(props: DoubleCheckHandlers = {}): Required<DoubleCheckHandlers> {
		const onBlur = () => setDoubleCheck(false)
		const onClick = doubleCheck
			? undefined
			: (event: Event) => {
					event.preventDefault()
					setDoubleCheck(true)
				}
		const onKeyUp = (event: KeyboardEvent) => {
			if (event.key === 'Escape') setDoubleCheck(false)
		}
		return {
			onBlur: callAll(onBlur, props.onBlur),
			onClick: callAll(onClick, props.onClick),
			onKeyUp: callAll(onKeyUp, props.onKeyUp as ((event: Event) => void) | undefined) as (
				event: KeyboardEvent,
			) => void,
		}
	}

	return {
		get doubleCheck() {
			return doubleCheck
		},
		setDoubleCheck,
		reset: () => setDoubleCheck(false),
		subscribe: (listener) => {
			listeners.add(listener)
			return () => {
				listeners.delete(listener)
			}
		},
		getButtonProps,
	}
}

export type TypeToConfirmController = {
	readonly value: string
	readonly matches: boolean
	readonly expected: string
	setValue: (value: string) => void
	reset: () => void
	subscribe: (listener: () => void) => () => void
	/** Runs `onConfirm` only when the typed value equals `expected`. */
	confirm: () => Promise<boolean>
}

/**
 * Type-to-confirm gate (Kody `confirm_name` style) for high-stakes deletes.
 *
 * @param input.expected - Exact string the user must type (e.g. package slug)
 * @param input.onConfirm - Called only when the typed value matches
 *
 * @example
 * import { createTypeToConfirm } from 'kody:@kentcdodds/package-app-kit/double-check'
 * const gate = createTypeToConfirm({
 *   expected: 'my-note',
 *   onConfirm: () => deleteNote('my-note'),
 * })
 * input.addEventListener('input', (e) => gate.setValue(e.target.value))
 * button.disabled = !gate.matches
 */
export function createTypeToConfirm(input: {
	expected: string
	onConfirm: () => void | Promise<void>
}): TypeToConfirmController {
	const expected = String(input.expected || '')
	let value = ''
	const listeners = new Set<() => void>()

	function notify() {
		for (const listener of listeners) listener()
	}

	return {
		get value() {
			return value
		},
		get matches() {
			return value === expected
		},
		get expected() {
			return expected
		},
		setValue(next: string) {
			value = String(next ?? '')
			notify()
		},
		reset() {
			value = ''
			notify()
		},
		subscribe(listener) {
			listeners.add(listener)
			return () => {
				listeners.delete(listener)
			}
		},
		async confirm() {
			if (value !== expected) return false
			await input.onConfirm()
			return true
		},
	}
}

/**
 * Minimal CSS for armed double-check buttons + type-to-confirm field.
 *
 * @example
 * import { doubleCheckCss } from 'kody:@kentcdodds/package-app-kit/double-check'
 * const css = doubleCheckCss()
 */
export function doubleCheckCss(): string {
	return `
.pak-btn[data-double-check][data-armed="true"],
.pak-btn.pak-danger[data-armed="true"] {
  color: var(--accent-ink);
  background: linear-gradient(180deg, var(--bad), color-mix(in srgb, var(--bad) 80%, #000));
}
.pak-type-confirm {
  display: grid;
  gap: 8px;
  margin-top: 10px;
}
.pak-type-confirm input {
  min-height: var(--tap);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 0 12px;
  font: inherit;
  background: var(--surface);
  color: var(--ink);
}
.pak-type-confirm .pak-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}
`.trim()
}


/** Primary callable export for this subpath. */
export default createDoubleCheck