72 lines
3.2 KiB
TypeScript
72 lines
3.2 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { DialogRoot, DialogContent, DialogFooter } from './ui/dialog';
|
|
import { Input } from './ui/input';
|
|
import { buttonVariants } from './ui/button';
|
|
import { cn } from '../lib/utils';
|
|
|
|
export interface ConfirmOptions {
|
|
title: string;
|
|
body?: ReactNode;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
danger?: boolean;
|
|
/** When set, the confirm button stays disabled until this exact text is typed. */
|
|
requireText?: string;
|
|
requireTextLabel?: string;
|
|
}
|
|
|
|
interface ConfirmRequest {
|
|
options: ConfirmOptions;
|
|
resolve: (ok: boolean) => void;
|
|
}
|
|
|
|
export function ConfirmDialog({request, onClose}: {request: ConfirmRequest | undefined; onClose: (ok: boolean) => void}) {
|
|
const [typed, setTyped] = useState('');
|
|
useEffect(() => { setTyped(''); }, [request]);
|
|
const options = request?.options;
|
|
const needsText = !!options?.requireText;
|
|
const confirmed = !needsText || typed === options?.requireText;
|
|
return <DialogRoot open={!!request} onOpenChange={open => { if (!open) onClose(false); }}>
|
|
{options && <DialogContent title={options.title} onOpenAutoFocus={e => { if (!needsText) return; e.preventDefault(); }}>
|
|
{options.body && <div className="mb-4 text-[13px] leading-relaxed text-muted-foreground wrap-anywhere">{options.body}</div>}
|
|
{needsText && <label className="mb-1 grid gap-1.5 text-[13px] font-medium">
|
|
{options.requireTextLabel || '输入完整标识以确认'}
|
|
<Input value={typed} onChange={e => setTyped(e.target.value)} autoFocus autoComplete="off" spellCheck={false} className="font-mono" placeholder={options.requireText}/>
|
|
</label>}
|
|
<DialogFooter>
|
|
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} onClick={() => onClose(false)}>{options.cancelLabel || '取消'}</button>
|
|
<button type="button" disabled={!confirmed} className={cn(buttonVariants({variant: options.danger ? 'destructive' : 'default'}))} onClick={() => onClose(true)}>{options.confirmLabel || '确认'}</button>
|
|
</DialogFooter>
|
|
</DialogContent>}
|
|
</DialogRoot>;
|
|
}
|
|
|
|
/**
|
|
* Promise-based replacement for window.confirm / window.prompt(confirm-id).
|
|
* Render {confirm.element} once near the page root; ask() resolves true/false.
|
|
* Plain functions (no useCallback) keep it usable in bare vnode-tree tests.
|
|
*/
|
|
export function useConfirm() {
|
|
const [request, setRequest] = useState<ConfirmRequest>();
|
|
// A ref tracks the live request so back-to-back ask() calls (e.g. a fast
|
|
// double submit before re-render) cancel the previous promise instead of
|
|
// leaking it. Direct value sets keep bare vnode-tree tests working.
|
|
const current = useRef<ConfirmRequest | undefined>(undefined);
|
|
useEffect(() => () => {
|
|
current.current?.resolve(false);
|
|
current.current = undefined;
|
|
}, []);
|
|
const ask = (options: ConfirmOptions) => new Promise<boolean>(resolve => {
|
|
current.current?.resolve(false);
|
|
const next: ConfirmRequest = {options, resolve};
|
|
current.current = next;
|
|
setRequest(next);
|
|
});
|
|
const close = (ok: boolean) => {
|
|
current.current?.resolve(ok);
|
|
current.current = undefined;
|
|
setRequest(undefined);
|
|
};
|
|
return {ask, element: <ConfirmDialog request={request} onClose={close}/>};
|
|
}
|