story-research-zapwall/lib/userConfirm.ts
2026-01-10 09:41:57 +01:00

152 lines
4.3 KiB
TypeScript

/**
* User confirmation utility
* Non-blocking confirmation overlay to avoid `window.confirm()` (`no-alert`).
* Used for critical confirmations (e.g. destructive actions) without requiring
* a React modal or additional global state.
*/
export function userConfirm(message: string): Promise<boolean> {
return confirmOverlay(message)
}
type ConfirmOverlayElements = {
overlay: HTMLDivElement
cancel: HTMLButtonElement
confirm: HTMLButtonElement
}
type ConfirmOverlayHandlerParams = ConfirmOverlayElements & {
resolve: (value: boolean) => void
}
function confirmOverlay(message: string): Promise<boolean> {
const doc = globalThis.document
if (!doc) {
return Promise.resolve(false)
}
return new Promise((resolve) => {
const { overlay, cancel, confirm } = buildConfirmOverlay(doc, message)
doc.body.append(overlay)
overlay.focus()
attachConfirmOverlayHandlers({ overlay, cancel, confirm, resolve })
})
}
function buildConfirmOverlay(doc: Document, message: string): ConfirmOverlayElements {
const overlay = createOverlay(doc)
const panel = createPanel(doc)
const text = createText(doc, message)
const buttons = createButtonsContainer(doc)
const cancel = createCancelButton(doc)
const confirm = createConfirmButton(doc)
buttons.append(cancel, confirm)
panel.append(text, buttons)
overlay.append(panel)
return { overlay, cancel, confirm }
}
function createOverlay(doc: Document): HTMLDivElement {
const overlay = doc.createElement('div')
overlay.setAttribute('role', 'dialog')
overlay.setAttribute('aria-modal', 'true')
overlay.tabIndex = -1
overlay.style.position = 'fixed'
overlay.style.inset = '0'
overlay.style.background = 'rgba(0,0,0,0.6)'
overlay.style.display = 'flex'
overlay.style.alignItems = 'center'
overlay.style.justifyContent = 'center'
overlay.style.zIndex = '9999'
return overlay
}
function createPanel(doc: Document): HTMLDivElement {
const panel = doc.createElement('div')
panel.style.background = '#fff'
panel.style.borderRadius = '12px'
panel.style.padding = '16px'
panel.style.maxWidth = '520px'
panel.style.width = 'calc(100% - 32px)'
panel.style.boxShadow = '0 10px 30px rgba(0,0,0,0.35)'
return panel
}
function createText(doc: Document, message: string): HTMLParagraphElement {
const text = doc.createElement('p')
text.textContent = message
text.style.margin = '0 0 16px 0'
text.style.color = '#111827'
return text
}
function createButtonsContainer(doc: Document): HTMLDivElement {
const buttons = doc.createElement('div')
buttons.style.display = 'flex'
buttons.style.gap = '12px'
buttons.style.justifyContent = 'flex-end'
return buttons
}
function createCancelButton(doc: Document): HTMLButtonElement {
const cancel = doc.createElement('button')
cancel.type = 'button'
cancel.textContent = 'Cancel'
cancel.style.padding = '8px 12px'
cancel.style.borderRadius = '10px'
cancel.style.border = '1px solid #e5e7eb'
cancel.style.background = '#f3f4f6'
return cancel
}
function createConfirmButton(doc: Document): HTMLButtonElement {
const confirm = doc.createElement('button')
confirm.type = 'button'
confirm.textContent = 'Confirm'
confirm.style.padding = '8px 12px'
confirm.style.borderRadius = '10px'
confirm.style.border = '1px solid #ef4444'
confirm.style.background = '#fee2e2'
confirm.style.color = '#991b1b'
return confirm
}
function attachConfirmOverlayHandlers(params: ConfirmOverlayHandlerParams): void {
const { overlay, cancel, confirm, resolve } = params
let resolved = false
const resolveOnce = (next: boolean): void => {
if (resolved) {
return
}
resolved = true
cleanup()
resolve(next)
}
const onCancel = (): void => resolveOnce(false)
const onConfirm = (): void => resolveOnce(true)
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.preventDefault()
resolveOnce(false)
return
}
if (e.key === 'Enter') {
e.preventDefault()
resolveOnce(true)
}
}
const cleanup = (): void => {
overlay.removeEventListener('keydown', onKeyDown)
cancel.removeEventListener('click', onCancel)
confirm.removeEventListener('click', onConfirm)
overlay.remove()
}
cancel.addEventListener('click', onCancel)
confirm.addEventListener('click', onConfirm)
overlay.addEventListener('keydown', onKeyDown)
}