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

182 lines
4.7 KiB
TypeScript

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';
type ConfirmVariant = 'default' | 'destructive';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
variant?: ConfirmVariant;
onConfirm?: () => void | Promise<void>;
loading?: boolean;
}
/* ── ConfirmDialog ──────────────────────────────────────────────
Reusable confirmation dialog that replaces window.confirm. */
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
onConfirm,
loading = false,
}: ConfirmDialogProps) {
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 });
// Render <ConfirmDialogComponent /> anywhere in the tree. */
interface ConfirmState {
open: boolean;
title: string;
description: React.ReactNode;
confirmLabel: string;
cancelLabel: string;
variant: ConfirmVariant;
loading: boolean;
}
interface ConfirmOptions {
title?: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
variant?: ConfirmVariant;
}
export function useConfirm() {
const [state, setState] = React.useState<ConfirmState>({
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<((value: boolean) => void) | null>(null);
const confirm = React.useCallback(
({
title = 'Are you sure?',
description = '',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
}: ConfirmOptions = {}): Promise<boolean> => {
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: boolean) => {
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}
/>
),
[state, handleConfirm, handleOpenChange]
);
return { confirm, ConfirmDialogComponent };
}