Files
MealQuest/web/src/settings/LeaveGuard.tsx
T
dev_agentandClaude Opus 5.5 ba83ad6df0
CI / ci (push) Successful in 2m57s
CI / ci (pull_request) Successful in 2m40s
Keep focus in a confirmation when a disabled button drops it to the page
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>
2026-10-07 04:39:55 -04:00

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>
)
}