2026-10-07 04:32:18 -04:00
|
|
|
import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from 'react'
|
2026-10-06 12:45:05 -04:00
|
|
|
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
|
2026-10-07 04:23:33 -04:00
|
|
|
// When false, the cancel button, Escape and the scrim do nothing, so the
|
|
|
|
|
// sheet stays until the action settles.
|
|
|
|
|
dismissible?: boolean
|
2026-10-06 12:45:05 -04:00
|
|
|
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,
|
2026-10-07 04:23:33 -04:00
|
|
|
dismissible = true,
|
2026-10-06 12:45:05 -04:00
|
|
|
error = null,
|
|
|
|
|
onCancel,
|
|
|
|
|
onConfirm,
|
|
|
|
|
}: ConfirmProps) {
|
|
|
|
|
const titleId = useId()
|
2026-10-07 04:32:18 -04:00
|
|
|
const sheetRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
// A focused button that becomes disabled loses focus to the page, outside
|
|
|
|
|
// the dialog; the dialog takes it instead.
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
const dialog = sheetRef.current?.closest<HTMLElement>('[role="dialog"]')
|
|
|
|
|
const active = document.activeElement
|
|
|
|
|
if (dialog && active instanceof HTMLElement && active.matches(':disabled') && dialog.contains(active)) dialog.focus()
|
|
|
|
|
}, [busy, dismissible])
|
2026-10-07 04:23:33 -04:00
|
|
|
function dismiss() {
|
|
|
|
|
if (dismissible) onCancel()
|
|
|
|
|
}
|
2026-10-06 12:45:05 -04:00
|
|
|
return (
|
2026-10-07 04:23:33 -04:00
|
|
|
<Sheet open={open} onClose={dismiss} aria-labelledby={titleId}>
|
2026-10-07 04:32:18 -04:00
|
|
|
<div ref={sheetRef} className={styles.sheet}>
|
2026-10-06 12:45:05 -04:00
|
|
|
<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}>
|
2026-10-07 04:23:33 -04:00
|
|
|
<Button variant="outline" disabled={!dismissible} onClick={onCancel}>
|
2026-10-06 12:45:05 -04:00
|
|
|
{cancelLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button disabled={busy} onClick={onConfirm}>
|
|
|
|
|
{confirmLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Sheet>
|
|
|
|
|
)
|
|
|
|
|
}
|