Confirm moved focus to its dialog only when the focused control stayed focused while disabled. A browser that moves focus to the page as it disables the control left the confirmation open with focus outside it. Confirm now also takes focus back to the dialog when busy or dismissible changes and focus is on the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
3.0 KiB
TypeScript
98 lines
3.0 KiB
TypeScript
import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from 'react'
|
|
import { useBlocker } from 'react-router'
|
|
import { Button } from '../ui/Button.tsx'
|
|
import { Sheet } from '../ui/Sheet.tsx'
|
|
import styles from './LeaveGuard.module.css'
|
|
|
|
// LeaveGuard asks before leaving the screen with unsaved changes.
|
|
export function LeaveGuard({ dirty }: { dirty: boolean }) {
|
|
const blocker = useBlocker(
|
|
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
|
)
|
|
useEffect(() => {
|
|
if (!dirty) return
|
|
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
|
window.addEventListener('beforeunload', onBeforeUnload)
|
|
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
|
}, [dirty])
|
|
return (
|
|
<Confirm
|
|
open={blocker.state === 'blocked'}
|
|
title="Leave without saving?"
|
|
confirmLabel="Leave"
|
|
cancelLabel="Keep editing"
|
|
onCancel={() => blocker.reset?.()}
|
|
onConfirm={() => blocker.proceed?.()}
|
|
>
|
|
Your changes on this screen have not been saved.
|
|
</Confirm>
|
|
)
|
|
}
|
|
|
|
type ConfirmProps = {
|
|
open: boolean
|
|
title: string
|
|
children: ReactNode
|
|
confirmLabel: string
|
|
cancelLabel?: string
|
|
busy?: boolean
|
|
// When false, the cancel button, Escape and the scrim do nothing, so the
|
|
// sheet stays until the action settles.
|
|
dismissible?: boolean
|
|
error?: string | null
|
|
onCancel: () => void
|
|
onConfirm: () => void
|
|
}
|
|
|
|
// Confirm is a sheet asking the household to confirm one action.
|
|
export function Confirm({
|
|
open,
|
|
title,
|
|
children,
|
|
confirmLabel,
|
|
cancelLabel = 'Cancel',
|
|
busy = false,
|
|
dismissible = true,
|
|
error = null,
|
|
onCancel,
|
|
onConfirm,
|
|
}: ConfirmProps) {
|
|
const titleId = useId()
|
|
const sheetRef = useRef<HTMLDivElement>(null)
|
|
// A focused button that becomes disabled either keeps focus while disabled or
|
|
// loses it to the page, depending on the browser; the dialog takes it instead.
|
|
useLayoutEffect(() => {
|
|
const dialog = sheetRef.current?.closest<HTMLElement>('[role="dialog"]')
|
|
if (!dialog) return
|
|
const active = document.activeElement
|
|
const lost = active === null || active === document.body || (dialog.contains(active) && active.matches(':disabled'))
|
|
if (lost) dialog.focus()
|
|
}, [busy, dismissible])
|
|
function dismiss() {
|
|
if (dismissible) onCancel()
|
|
}
|
|
return (
|
|
<Sheet open={open} onClose={dismiss} aria-labelledby={titleId}>
|
|
<div ref={sheetRef} className={styles.sheet}>
|
|
<h2 id={titleId} className={styles.title}>
|
|
{title}
|
|
</h2>
|
|
<p className={styles.text}>{children}</p>
|
|
{error !== null && (
|
|
<p className={styles.error} role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div className={styles.actions}>
|
|
<Button variant="outline" disabled={!dismissible} onClick={onCancel}>
|
|
{cancelLabel}
|
|
</Button>
|
|
<Button disabled={busy} onClick={onConfirm}>
|
|
{confirmLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Sheet>
|
|
)
|
|
}
|