You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
128 lines
3.3 KiB
TypeScript
128 lines
3.3 KiB
TypeScript
// frontend/components/Dialog.tsx
|
|
// 앱 전역 통합 다이얼로그 — window.confirm/alert 대체.
|
|
// useDialog().confirm({...}) → Promise<boolean>, .alert({...}) → Promise<void>.
|
|
"use client";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
type DialogTone = "danger" | "primary";
|
|
export type DialogOptions = {
|
|
title?: string;
|
|
message?: string;
|
|
confirmText?: string;
|
|
cancelText?: string; // confirm 전용
|
|
tone?: DialogTone;
|
|
};
|
|
type DialogState = DialogOptions & { kind: "confirm" | "alert" };
|
|
|
|
type DialogApi = {
|
|
confirm: (opts: DialogOptions) => Promise<boolean>;
|
|
alert: (opts: DialogOptions) => Promise<void>;
|
|
};
|
|
|
|
const Ctx = createContext<DialogApi | null>(null);
|
|
|
|
export function useDialog(): DialogApi {
|
|
const c = useContext(Ctx);
|
|
if (!c) throw new Error("useDialog must be used within <DialogProvider>");
|
|
return c;
|
|
}
|
|
|
|
function DialogModal({
|
|
state,
|
|
onClose,
|
|
}: {
|
|
state: DialogState;
|
|
onClose: (ok: boolean) => void;
|
|
}) {
|
|
const isConfirm = state.kind === "confirm";
|
|
const tone: DialogTone = state.tone ?? (isConfirm ? "danger" : "primary");
|
|
const okRef = useRef<HTMLButtonElement>(null);
|
|
|
|
useEffect(() => {
|
|
okRef.current?.focus();
|
|
const h = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
onClose(false);
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
onClose(true);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", h);
|
|
return () => window.removeEventListener("keydown", h);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className="dlg-backdrop" onClick={() => onClose(false)} role="presentation">
|
|
<div
|
|
className="dlg-card"
|
|
role="alertdialog"
|
|
aria-modal="true"
|
|
aria-label={state.title ?? "확인"}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{state.title && <h2 className="dlg-title">{state.title}</h2>}
|
|
{state.message && <p className="dlg-msg">{state.message}</p>}
|
|
<div className="dlg-actions">
|
|
{isConfirm && (
|
|
<button className="dlg-btn ghost" onClick={() => onClose(false)}>
|
|
{state.cancelText ?? "취소"}
|
|
</button>
|
|
)}
|
|
<button
|
|
ref={okRef}
|
|
className={"dlg-btn " + tone}
|
|
onClick={() => onClose(true)}
|
|
>
|
|
{state.confirmText ?? "확인"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|
const [state, setState] = useState<DialogState | null>(null);
|
|
const resolver = useRef<((ok: boolean) => void) | null>(null);
|
|
|
|
const close = useCallback((ok: boolean) => {
|
|
resolver.current?.(ok);
|
|
resolver.current = null;
|
|
setState(null);
|
|
}, []);
|
|
|
|
const confirm = useCallback(
|
|
(opts: DialogOptions) =>
|
|
new Promise<boolean>((resolve) => {
|
|
resolver.current = resolve;
|
|
setState({ kind: "confirm", ...opts });
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const alert = useCallback(
|
|
(opts: DialogOptions) =>
|
|
new Promise<void>((resolve) => {
|
|
resolver.current = () => resolve();
|
|
setState({ kind: "alert", ...opts });
|
|
}),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<Ctx.Provider value={{ confirm, alert }}>
|
|
{children}
|
|
{state && <DialogModal state={state} onClose={close} />}
|
|
</Ctx.Provider>
|
|
);
|
|
}
|