BillTracker/client/components/ui/confirm-dialog.jsx

166 lines
4.8 KiB
JavaScript

import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
/* ── ConfirmDialog ──────────────────────────────────────────────
Reusable confirmation dialog that replaces window.confirm.
Props:
open boolean
onOpenChange (open: boolean) => void
title string
description string | ReactNode
confirmLabel string (default: 'Confirm')
cancelLabel string (default: 'Cancel')
variant 'default' | 'destructive'
onConfirm () => void | Promise<void>
loading boolean
─────────────────────────────────────────────────────────────── */
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
onConfirm,
loading = false,
}) {
const handleConfirm = async () => {
await onConfirm?.();
};
const handleCancel = () => {
if (!loading) onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={loading ? undefined : onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<DialogDescription>{description}</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleCancel}
disabled={loading}
>
{cancelLabel}
</Button>
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={loading}
className={cn(loading && 'cursor-not-allowed')}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/* ── useConfirm hook ────────────────────────────────────────────
Imperative API for ConfirmDialog.
Usage:
const { confirm, ConfirmDialogComponent } = useConfirm();
// ...
const ok = await confirm({ title, description, confirmLabel, variant });
// Returns true if confirmed, false if cancelled.
// Render <ConfirmDialogComponent /> anywhere in the tree.
─────────────────────────────────────────────────────────────── */
export function useConfirm() {
const [state, setState] = React.useState({
open: false,
title: '',
description: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
variant: 'default',
loading: false,
});
// Keep a stable ref to the resolve callback so it survives re-renders
const resolveRef = React.useRef(null);
const confirm = React.useCallback(
({
title = 'Are you sure?',
description = '',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
} = {}) => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setState({
open: true,
title,
description,
confirmLabel,
cancelLabel,
variant,
loading: false,
});
});
},
[]
);
const handleConfirm = React.useCallback(async () => {
setState((s) => ({ ...s, loading: true }));
resolveRef.current?.(true);
resolveRef.current = null;
setState((s) => ({ ...s, open: false, loading: false }));
}, []);
const handleOpenChange = React.useCallback((open) => {
if (!open) {
resolveRef.current?.(false);
resolveRef.current = null;
setState((s) => ({ ...s, open: false }));
}
}, []);
const ConfirmDialogComponent = React.useCallback(
() => (
<ConfirmDialog
open={state.open}
onOpenChange={handleOpenChange}
title={state.title}
description={state.description}
confirmLabel={state.confirmLabel}
cancelLabel={state.cancelLabel}
variant={state.variant}
loading={state.loading}
onConfirm={handleConfirm}
/>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[state, handleConfirm, handleOpenChange]
);
return { confirm, ConfirmDialogComponent };
}