import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren, type ReactNode, } from 'react'; import { useBlocker } from 'react-router-dom'; import { Modal } from './modal.js'; interface UnsavedChangesContextValue { register(id: string, isDirty: boolean): void; release(id: string): void; } const UnsavedChangesContext = createContext(null); /** * Page-level guard for a screen made of independently saved cards. * * Each card reports its own dirty state by id, so the guard can warn about * leaving while *any* card has unsaved edits without the cards needing to * share a draft. Section switches within a page do not trip it: only a * pathname change is blocked. */ export function UnsavedChangesProvider({ children }: PropsWithChildren): ReactNode { const [dirtyIds, setDirtyIds] = useState([]); const register = useCallback((id: string, isDirty: boolean) => { setDirtyIds((current) => { const isTracked = current.includes(id); if (isDirty === isTracked) return current; return isDirty ? [...current, id] : current.filter((item) => item !== id); }); }, []); const release = useCallback((id: string) => { setDirtyIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : current)); }, []); const value = useMemo(() => ({ register, release }), [register, release]); const hasUnsavedChanges = dirtyIds.length > 0; useEffect(() => { if (!hasUnsavedChanges) return; function warnBeforeUnload(event: BeforeUnloadEvent) { event.preventDefault(); event.returnValue = ''; } window.addEventListener('beforeunload', warnBeforeUnload); return () => window.removeEventListener('beforeunload', warnBeforeUnload); }, [hasUnsavedChanges]); const blocker = useBlocker(({ currentLocation, nextLocation }) => hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname); return ( {children} {blocker.state === 'blocked' ? ( blocker.reset()}> Unsaved changes

Leave without saving?

{dirtyIds.length === 1 ? 'One section has edits that have not been saved. Leaving will discard them.' : `${dirtyIds.length} sections have edits that have not been saved. Leaving will discard them.`}

) : null}
); } /** * Reports a card's dirty state to the surrounding {@link UnsavedChangesProvider}. * Safe to call with no provider mounted, which keeps cards usable in isolation. */ export function useUnsavedChangesFlag(id: string, isDirty: boolean): void { const context = useContext(UnsavedChangesContext); useEffect(() => { context?.register(id, isDirty); }, [context, id, isDirty]); useEffect(() => () => context?.release(id), [context, id]); }