返回 DeepSeek-Reasonix
ConfirmDialog.tsx
根目录 / desktop / frontend / src / components / ConfirmDialog.tsx
1 import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2 import { createPortal } from "react-dom";
3
4 export type ConfirmDialogRequest = {
5 title: string;
6 message: ReactNode;
7 confirmLabel: string;
8 cancelLabel: string;
9 tone?: "default" | "danger";
10 };
11
12 type PendingConfirmation = ConfirmDialogRequest & {
13 resolve: (confirmed: boolean) => void;
14 };
15
16 function ConfirmDialog({ request, onResolve }: { request: ConfirmDialogRequest; onResolve: (confirmed: boolean) => void }) {
17 const titleId = useId();
18 const messageId = useId();
19 const cancelRef = useRef<HTMLButtonElement>(null);
20 const confirmRef = useRef<HTMLButtonElement>(null);
21 const restoreFocusRef = useRef<HTMLElement | null>(null);
22
23 useLayoutEffect(() => {
24 restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
25 cancelRef.current?.focus();
26 return () => {
27 if (restoreFocusRef.current?.isConnected) restoreFocusRef.current.focus();
28 };
29 }, []);
30
31 useEffect(() => {
32 const onKeyDown = (event: KeyboardEvent) => {
33 if (event.key === "Escape") {
34 event.preventDefault();
35 event.stopPropagation();
36 onResolve(false);
37 return;
38 }
39 if (event.key !== "Tab") return;
40 const first = cancelRef.current;
41 const last = confirmRef.current;
42 if (!first || !last) return;
43 if (event.shiftKey && document.activeElement === first) {
44 event.preventDefault();
45 last.focus();
46 } else if (!event.shiftKey && document.activeElement === last) {
47 event.preventDefault();
48 first.focus();
49 }
50 };
51 document.addEventListener("keydown", onKeyDown, { capture: true });
52 return () => document.removeEventListener("keydown", onKeyDown, { capture: true });
53 }, [onResolve]);
54
55 return createPortal(
56 <div
57 data-app-overlay=""
58 className="modal-backdrop reasonix-confirm-backdrop"
59 role="presentation"
60 onMouseDown={(event) => {
61 if (event.target === event.currentTarget) onResolve(false);
62 }}
63 >
64 <div
65 className="modal reasonix-confirm-dialog"
66 role="dialog"
67 aria-modal="true"
68 aria-labelledby={titleId}
69 aria-describedby={messageId}
70 >
71 <div className="modal__title reasonix-confirm-dialog__title" id={titleId}>{request.title}</div>
72 <div className="reasonix-confirm-dialog__message" id={messageId}>{request.message}</div>
73 <div className="modal__actions reasonix-confirm-dialog__actions">
74 <button ref={cancelRef} className="btn btn--small" type="button" onClick={() => onResolve(false)}>
75 {request.cancelLabel}
76 </button>
77 <button
78 ref={confirmRef}
79 className={`btn btn--small ${request.tone === "danger" ? "btn--danger" : "btn--primary"}`}
80 type="button"
81 onClick={() => onResolve(true)}
82 >
83 {request.confirmLabel}
84 </button>
85 </div>
86 </div>
87 </div>,
88 document.body,
89 );
90 }
91
92 export function useConfirmDialog(): {
93 confirm: (request: ConfirmDialogRequest) => Promise<boolean>;
94 dialog: ReactNode;
95 dismiss: () => void;
96 } {
97 const [pending, setPending] = useState<PendingConfirmation | null>(null);
98 const pendingRef = useRef<PendingConfirmation | null>(null);
99
100 const resolvePending = useCallback((confirmed: boolean) => {
101 const current = pendingRef.current;
102 if (!current) return;
103 pendingRef.current = null;
104 setPending(null);
105 current.resolve(confirmed);
106 }, []);
107
108 const confirm = useCallback((request: ConfirmDialogRequest) => new Promise<boolean>((resolve) => {
109 pendingRef.current?.resolve(false);
110 const next = { ...request, resolve };
111 pendingRef.current = next;
112 setPending(next);
113 }), []);
114
115 useEffect(() => () => {
116 pendingRef.current?.resolve(false);
117 pendingRef.current = null;
118 }, []);
119
120 const dismiss = useCallback(() => resolvePending(false), [resolvePending]);
121 return {
122 confirm, dismiss,
123 dialog: pending ? <ConfirmDialog request={pending} onResolve={resolvePending} /> : null,
124 };
125 }
126
126 lines Plain Text