Files
dsh-web/web/src/components/ConfirmDialog.tsx
T

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}/>};
}