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:
2026-07-02 09:36:08 +07:00
co-authored by Claude Opus 4.8
parent fcbe438f32
commit 29ccbc22e9
6 changed files with 254 additions and 3 deletions
+88
View File
@@ -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,
)
}