← 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