feat(web): reusable Modal + ConfirmProvider, replace native confirm()
Modal component: portal, ESC to close, Tab focus-trap, focus-on-open
(prefers [data-modal-autofocus]), focus restore, overlay click, scroll lock.
ConfirmProvider exposes useConfirm(): async confirm({...}) as a drop-in for
window.confirm; Enter confirms, ESC cancels. Task/account deletes now use it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
|
||||
export type ConfirmOptions = {
|
||||
title?: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn | null>(null)
|
||||
|
||||
// Drop-in async replacement for window.confirm(): `await confirm({ message })`.
|
||||
export function useConfirm(): ConfirmFn {
|
||||
const ctx = useContext(ConfirmContext)
|
||||
if (!ctx) throw new Error('useConfirm must be used within <ConfirmProvider>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [opts, setOpts] = useState<ConfirmOptions | null>(null)
|
||||
const resolver = useRef<(v: boolean) => void>(() => {})
|
||||
|
||||
const confirm = useCallback<ConfirmFn>((o) => {
|
||||
setOpts(o)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolver.current = resolve
|
||||
})
|
||||
}, [])
|
||||
|
||||
const finish = useCallback((result: boolean) => {
|
||||
resolver.current(result)
|
||||
resolver.current = () => {}
|
||||
setOpts(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<Modal open={opts !== null} title={opts?.title ?? 'Confirm'} onClose={() => finish(false)}>
|
||||
{opts && (
|
||||
<div
|
||||
className="confirm-body"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
finish(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="confirm-message">{opts.message}</p>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={() => finish(false)}>
|
||||
{opts.cancelLabel ?? 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={opts.danger ? 'btn btn-danger' : 'btn btn-primary'}
|
||||
data-modal-autofocus
|
||||
onClick={() => finish(true)}
|
||||
>
|
||||
{opts.confirmLabel ?? 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</ConfirmContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
title?: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function focusable(root: HTMLElement | null): HTMLElement[] {
|
||||
if (!root) return []
|
||||
const sel = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(sel)).filter(
|
||||
(el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true',
|
||||
)
|
||||
}
|
||||
|
||||
// Reusable modal shell: portal to <body>, ESC to close, Tab focus-trap,
|
||||
// focus-on-open (prefers [data-modal-autofocus]), focus restore on close,
|
||||
// click-on-overlay to close, and body scroll lock while open.
|
||||
export function Modal({ open, title, onClose, children }: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const prevFocus = useRef<HTMLElement | null>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
prevFocus.current = document.activeElement as HTMLElement | null
|
||||
|
||||
const auto = dialogRef.current?.querySelector<HTMLElement>('[data-modal-autofocus]')
|
||||
;(auto ?? focusable(dialogRef.current)[0] ?? dialogRef.current)?.focus()
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCloseRef.current()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const items = focusable(dialogRef.current)
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const inside = dialogRef.current?.contains(active)
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !inside) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else if (active === last || !inside) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
document.body.style.overflow = prevOverflow
|
||||
prevFocus.current?.focus?.()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="modal-dialog" role="dialog" aria-modal="true" aria-label={title} ref={dialogRef} tabIndex={-1}>
|
||||
{title && <div className="modal-title">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user