diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx index 532fe72..3252da5 100644 --- a/packages/ui/src/dialog.tsx +++ b/packages/ui/src/dialog.tsx @@ -12,36 +12,45 @@ export function Dialog(props: { footer?: ReactNode children: ReactNode }) { - const prevFocus = useRef(null) const cardRef = useRef(null) + const onCloseRef = useRef(props.onClose) + onCloseRef.current = props.onClose useEffect(() => { - if (props.open) { - prevFocus.current = document.activeElement as HTMLElement | null - const first = cardRef.current?.querySelector( - 'input, select, textarea, button:not(.wf-dialog-x)', - ) - ;(first ?? cardRef.current ?? undefined)?.focus() - } + if (!props.open) return + const prev = document.activeElement as HTMLElement | null + const nodes = cardRef.current?.querySelectorAll( + 'input, select, textarea, button:not(.wf-dialog-x)', + ) + const first = nodes === undefined ? undefined : [...nodes].find((n) => !n.hasAttribute('disabled')) + ;(first ?? cardRef.current ?? undefined)?.focus() + // Restore focus on close AND on unmount-while-open ({show && } pattern). + return () => prev?.focus() }, [props.open]) + + // 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 ). useEffect(() => { - if (!props.open) prevFocus.current?.focus() + if (!props.open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); onCloseRef.current(); return } + if (e.key !== 'Tab') return + const nodes = cardRef.current?.querySelectorAll(FOCUSABLE) + const list = nodes === undefined ? [] : [...nodes].filter((n) => !n.hasAttribute('disabled')) + const first = list[0] + const last = list[list.length - 1] + if (first === undefined || last === undefined) { e.preventDefault(); return } + const active = document.activeElement + 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 - const onKey = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { e.preventDefault(); props.onClose(); return } - if (e.key !== 'Tab') return - const nodes = cardRef.current?.querySelectorAll(FOCUSABLE) - const list = nodes === undefined ? [] : [...nodes].filter((n) => !n.hasAttribute('disabled')) - const first = list[0] - const last = list[list.length - 1] - if (first === undefined || last === undefined) { e.preventDefault(); return } - if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() } - } - return (
e.stopPropagation()} - onKeyDown={onKey} >

{props.title}

@@ -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: { open: boolean onClose: () => void @@ -77,11 +87,21 @@ export function ConfirmDialog(props: { tone?: 'danger' }) { const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + useEffect(() => { if (props.open) setError(null) }, [props.open]) + const close = () => { if (!busy) props.onClose() } const confirm = () => { + setError(null) const out = props.onConfirm() if (out instanceof Promise) { 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 { props.onClose() } @@ -89,12 +109,12 @@ export function ConfirmDialog(props: { return ( - + ) }