'use client' import * as React from 'react' import { cn } from '../../utils/cn' import { Button } from './button' import { ModalV2, ModalV2Content, ModalV2Footer, ModalV2Header, ModalV2Title } from './modal-v2' /** * The unified dirty-state contract for modals: a promise-based house confirm * (`useConfirm`), a close guard that asks before discarding unsaved edits * (`useGuardedClose`), and the standing "Unsaved changes" footer chip * (`UnsavedChangesChip`). Form-modal shells compose these three instead of * reimplementing the affordance — closing a dirty form must never silently * discard the edits, and the chip is the standing signal that edits exist. */ /** * State-driven confirm dialog using `ModalV2` — the house replacement for * `window.confirm`. `ask(title, body)` returns a `Promise`; the * consumer awaits the user's choice and then proceeds. The returned `dialog` * element MUST be rendered in the host's JSX (inside the host modal is fine — * `ModalV2` stacks). * * Concurrency: a second `ask` while a dialog is already open resolves `false` * immediately — without the guard the second `setPending` would overwrite the * first entry and the first Promise would hang forever. The ref-based * in-flight flag also covers two synchronous `ask()` calls in the same render * tick, which `useState` alone races. */ export function useConfirm() { const [open, setOpen] = React.useState(false) const [pending, setPending] = React.useState<{ title: string body: string resolve: (ok: boolean) => void } | null>(null) const askingRef = React.useRef(false) // Mirror of the active request, so unmount cleanup can settle it without // depending on (already-frozen) state. const pendingRef = React.useRef<{ resolve: (ok: boolean) => void } | null>(null) const ask = React.useCallback((title: string, body: string) => { return new Promise((resolve) => { if (askingRef.current) { resolve(false) return } askingRef.current = true const entry = { title, body, resolve } pendingRef.current = entry setPending(entry) setOpen(true) }) }, []) const decide = React.useCallback( (ok: boolean) => { askingRef.current = false // Clear the ref FIRST so a later unmount cleanup can't re-resolve a // request the user already decided. pendingRef.current = null setOpen(false) pending?.resolve(ok) setPending(null) }, [pending] ) // Host unmounted while the confirmation was open: the decision UI is gone, // so settle the promise as "no" — otherwise the caller awaits forever. React.useEffect( () => () => { pendingRef.current?.resolve(false) pendingRef.current = null askingRef.current = false }, [] ) const dialog = pending ? ( decide(false)}> {pending.title}

{pending.body}

) : null return { ask, dialog } } export interface UseGuardedCloseOptions { /** Confirm dialog title. */ title?: string /** Confirm dialog body. */ body?: string } /** * Close guard for dirty forms: `guardedClose` runs `onClose` directly while * clean, and asks the house confirm first once `dirty` — wire it to the * modal's `onClose` AND the Cancel button so every dismissal path is guarded. * Render the returned `dialog` in the host's JSX. */ export function useGuardedClose( dirty: boolean, onClose: () => void, { title = 'Discard unsaved changes?', body = 'Your edits have not been saved. Close and discard them?' }: UseGuardedCloseOptions = {} ) { const { ask, dialog } = useConfirm() const guardedClose = React.useCallback(async () => { if (dirty && !(await ask(title, body))) return onClose() }, [dirty, ask, title, body, onClose]) return { guardedClose, dialog } } export interface UnsavedChangesChipProps { /** Names WHAT is dirty (hover tooltip) — turns "why is this dirty?!" into a hover. */ detail?: string className?: string } /** The standing "Unsaved changes" signal for a dirty modal's footer. */ export function UnsavedChangesChip({ detail, className }: UnsavedChangesChipProps) { return ( Unsaved changes ) }