| 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 |