166 lines
4.8 KiB
React
166 lines
4.8 KiB
React
|
|
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 };
|
||
|
|
}
|