fix(ui): ConfirmDialog error handling + Dialog focus hardening

- ConfirmDialog: a rejected onConfirm no longer closes the dialog as if it
  succeeded; the rejection is handled (no unhandledrejection), busy clears,
  and the error message is shown inline (role=alert)
- ConfirmDialog: Cancel / scrim / Escape / X are gated while busy, so the
  dialog cannot be dismissed (and onClose cannot double-fire) mid-flight
- Dialog: Escape + Tab trap moved to a document-level keydown listener so
  they still work when focus escapes the card (e.g. lands on <body>)
- Dialog: focus restore now runs via effect cleanup, covering the
  {show && <Dialog/>} unmount-while-open pattern
- Dialog: initial focus skips disabled controls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 2fbb4c6442
commit 0b46c03530

@ -12,35 +12,44 @@ export function Dialog(props: {
footer?: ReactNode footer?: ReactNode
children: ReactNode children: ReactNode
}) { }) {
const prevFocus = useRef<HTMLElement | null>(null)
const cardRef = useRef<HTMLDivElement>(null) const cardRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(props.onClose)
onCloseRef.current = props.onClose
useEffect(() => { useEffect(() => {
if (props.open) { if (!props.open) return
prevFocus.current = document.activeElement as HTMLElement | null const prev = document.activeElement as HTMLElement | null
const first = cardRef.current?.querySelector<HTMLElement>( const nodes = cardRef.current?.querySelectorAll<HTMLElement>(
'input, select, textarea, button:not(.wf-dialog-x)', 'input, select, textarea, button:not(.wf-dialog-x)',
) )
const first = nodes === undefined ? undefined : [...nodes].find((n) => !n.hasAttribute('disabled'))
;(first ?? cardRef.current ?? undefined)?.focus() ;(first ?? cardRef.current ?? undefined)?.focus()
} // Restore focus on close AND on unmount-while-open ({show && <Dialog/>} pattern).
}, [props.open]) return () => prev?.focus()
useEffect(() => {
if (!props.open) prevFocus.current?.focus()
}, [props.open]) }, [props.open])
if (!props.open) return null // Escape + Tab trap on document, so they work even when focus escapes the card
// (e.g. after a click on a disabled control lands focus on <body>).
const onKey = (e: React.KeyboardEvent) => { useEffect(() => {
if (e.key === 'Escape') { e.preventDefault(); props.onClose(); return } if (!props.open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCloseRef.current(); return }
if (e.key !== 'Tab') return if (e.key !== 'Tab') return
const nodes = cardRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) const nodes = cardRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE)
const list = nodes === undefined ? [] : [...nodes].filter((n) => !n.hasAttribute('disabled')) const list = nodes === undefined ? [] : [...nodes].filter((n) => !n.hasAttribute('disabled'))
const first = list[0] const first = list[0]
const last = list[list.length - 1] const last = list[list.length - 1]
if (first === undefined || last === undefined) { e.preventDefault(); return } if (first === undefined || last === undefined) { e.preventDefault(); return }
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() } const active = document.activeElement
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() } if (!(cardRef.current?.contains(active) ?? false)) { e.preventDefault(); first.focus(); return }
if (e.shiftKey && active === first) { e.preventDefault(); last.focus() }
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
} }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [props.open])
if (!props.open) return null
return ( return (
<div className="wf-dialog-back" onClick={props.onClose}> <div className="wf-dialog-back" onClick={props.onClose}>
@ -53,7 +62,6 @@ export function Dialog(props: {
aria-label={props.title} aria-label={props.title}
tabIndex={-1} tabIndex={-1}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={onKey}
> >
<div className="wf-dialog-head"> <div className="wf-dialog-head">
<h2>{props.title}</h2> <h2>{props.title}</h2>
@ -66,7 +74,9 @@ export function Dialog(props: {
) )
} }
/** Small confirm variant — kills window.confirm. Busy state while onConfirm resolves. */ /** Small confirm variant kills window.confirm. Busy state while onConfirm resolves.
* A rejected onConfirm keeps the dialog open and shows the error; close paths are
* disabled while the action is in flight. */
export function ConfirmDialog(props: { export function ConfirmDialog(props: {
open: boolean open: boolean
onClose: () => void onClose: () => void
@ -77,11 +87,21 @@ export function ConfirmDialog(props: {
tone?: 'danger' tone?: 'danger'
}) { }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => { if (props.open) setError(null) }, [props.open])
const close = () => { if (!busy) props.onClose() }
const confirm = () => { const confirm = () => {
setError(null)
const out = props.onConfirm() const out = props.onConfirm()
if (out instanceof Promise) { if (out instanceof Promise) {
setBusy(true) setBusy(true)
out.finally(() => { setBusy(false); props.onClose() }) out.then(
() => { setBusy(false); props.onClose() },
(e: unknown) => {
setBusy(false)
setError(e instanceof Error ? e.message : String(e))
},
)
} else { } else {
props.onClose() props.onClose()
} }
@ -89,12 +109,12 @@ export function ConfirmDialog(props: {
return ( return (
<Dialog <Dialog
open={props.open} open={props.open}
onClose={props.onClose} onClose={close}
title={props.title} title={props.title}
size="sm" size="sm"
footer={ footer={
<> <>
<button type="button" className="wf pill" onClick={props.onClose}>Cancel</button> <button type="button" className="wf pill" onClick={close} disabled={busy}>Cancel</button>
<button <button
type="button" type="button"
className={`wf pill ${props.tone === 'danger' ? 'danger' : 'primary'}`} className={`wf pill ${props.tone === 'danger' ? 'danger' : 'primary'}`}
@ -107,6 +127,9 @@ export function ConfirmDialog(props: {
} }
> >
{props.body !== undefined && <div style={{ fontSize: 13.5, color: 'var(--text-dim)' }}>{props.body}</div>} {props.body !== undefined && <div style={{ fontSize: 13.5, color: 'var(--text-dim)' }}>{props.body}</div>}
{error !== null && (
<div role="alert" style={{ marginTop: 8, fontSize: 13, color: 'var(--err)' }}>{error}</div>
)}
</Dialog> </Dialog>
) )
} }

Loading…
Cancel
Save