返回 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 className="modal-backdrop reasonix-confirm-backdrop"
58 role="presentation"
59 onMouseDown={(event) => {
60 if (event.target === event.currentTarget) onResolve(false);
61 }}
62 >
63 <div
64 className="modal reasonix-confirm-dialog"
65 role="dialog"
66 aria-modal="true"
67 aria-labelledby={titleId}
68 aria-describedby={messageId}
69 >
70 <div className="modal__title reasonix-confirm-dialog__title" id={titleId}>{request.title}</div>
71 <div className="reasonix-confirm-dialog__message" id={messageId}>{request.message}</div>
72 <div className="modal__actions reasonix-confirm-dialog__actions">
73 <button ref={cancelRef} className="btn btn--small" type="button" onClick={() => onResolve(false)}>
74 {request.cancelLabel}
75 </button>
76 <button
77 ref={confirmRef}
78 className={`btn btn--small ${request.tone === "danger" ? "btn--danger" : "btn--primary"}`}
79 type="button"
80 onClick={() => onResolve(true)}
81 >
82 {request.confirmLabel}
83 </button>
84 </div>
85 </div>
86 </div>,
87 document.body,
88 );
89 }
90
91 export function useConfirmDialog(): {
92 confirm: (request: ConfirmDialogRequest) => Promise<boolean>;
93 dialog: ReactNode;
94 } {
95 const [pending, setPending] = useState<PendingConfirmation | null>(null);
96 const pendingRef = useRef<PendingConfirmation | null>(null);
97
98 const resolvePending = useCallback((confirmed: boolean) => {
99 const current = pendingRef.current;
100 if (!current) return;
101 pendingRef.current = null;
102 setPending(null);
103 current.resolve(confirmed);
104 }, []);
105
106 const confirm = useCallback((request: ConfirmDialogRequest) => new Promise<boolean>((resolve) => {
107 pendingRef.current?.resolve(false);
108 const next = { ...request, resolve };
109 pendingRef.current = next;
110 setPending(next);
111 }), []);
112
113 useEffect(() => () => {
114 pendingRef.current?.resolve(false);
115 pendingRef.current = null;
116 }, []);
117
118 return {
119 confirm,
120 dialog: pending ? <ConfirmDialog request={pending} onResolve={resolvePending} /> : null,
121 };
122 }
123
123 lines Plain Text