React에서 dialog 만들기
개발 •

바쁘다는 핑계로 너무 오랜만에 글을 올리는 것 같네요. 😥
1일 1커밋 그거 뭐 별일인가 하는 생각으로 살고는 있지만, 그것관 별개로 770일간 쌓아 올린 탑이 무너지는 건 슬픈 일이라 일하면서도 꾸준히 contribute은 진행하고 있습니다.
그러다 보니 퇴근하고 코드 좀 치면 산책(매일 한 시간 이상은 뒷산을 걷습니다) 나갈 시간이라 블로그에 통 신경을 못 쓰고 있었네요.
ua에 내장된 window.confirm
, window.alert
, window.prompt
등 클래식함을 아득히 지나쳐 낡은 디자인을 가진 창들을 간단하게 대체하는 방법입니다.
폴더 구조를 포함한 코드는 Github에서 확인하실 수 있습니다.
전역 상태 관리 도구로 zustand를 사용했는데, 보일러 플레이트가 적으니 사용하시는 스택으로 옮겨가시기 편하지 않을까 싶습니다.
export type ResponseHandler<T = unknown> = (value: T | PromiseLike<T>) => void;
export type DialogType = "alert" | "confirm" | "prompt";
export interface DialogStore<T = unknown> {
title: string;
setTitle(text: string): void;
description: string;
setDescription(description: string): void;
type: DialogType;
setType(state: DialogType): void;
revealed: boolean;
setRevealed: (show: boolean) => void;
responseHandler?: ResponseHandler<T>;
setResponseHandler(responseHandler: ResponseHandler<T>): void;
}
store의 타입입니다.
자세한 설명은 아래에서 하겠습니다.
import create from "zustand";
import { DialogStore } from "../@types/useDialogStore";
export default create<DialogStore>((set) => ({
title: "",
setTitle(title) {
set((prev) => ({ ...prev, title }));
},
description: "",
setDescription(description) {
set((prev) => ({ ...prev, description }));
},
type: "alert",
setType(type) {
set((prev) => ({ ...prev, type }));
},
revealed: false,
setRevealed(revealed) {
set((prev) => ({ ...prev, revealed }));
},
setResponseHandler(responseHandler) {
set((prev) => ({ ...prev, responseHandler }));
},
}));
WEBSITE says
가 들어갈 title
과 내용을 저장할 description
을 제일 먼저 만들었습니다.
다음으로 alert
, confirm
, prompt
세 타입 중 현재 띄운 창이 어떤 것일지를 저장할 곳도 만들었습니다.
마지막으로 창의 표시 여부 상태를 저장할 곳을 만들고, 기존에 alert
등이 동작하는 것처럼 후의 동작을 await
으로 막기 위해 Promise
를 사용할 건데, executor 중 resolve
를 responseHandler
에 담을 예정입니다.
import useDialogStore from "../store/useDialogStore";
import { DialogType } from "../@types/useDialogStore";
export default function useDialog() {
const {
setTitle,
setDescription,
setRevealed,
setType,
responseHandler,
setResponseHandler,
} = useDialogStore();
const onInteractionEnd = (value: string | boolean) => {
setRevealed(false);
responseHandler?.(value);
setTitle("");
setDescription("");
};
const setAttributes = (
type: DialogType,
title: string,
description: string
) => {
setRevealed(true);
setTitle(title);
setDescription(description);
setType(type);
};
const confirm = (title: string, description = "") => {
setAttributes("confirm", title, description);
return new Promise<boolean>((res) => {
setResponseHandler(res);
});
};
const alert = (title: string, description = "") => {
setAttributes("alert", title, description);
return new Promise<boolean>((res) => {
setResponseHandler(res);
});
};
const prompt = (title: string, description = "") => {
setAttributes("prompt", title, description);
return new Promise<boolean>((res) => {
setResponseHandler(res);
});
};
return {
confirm,
alert,
prompt,
onInteractionEnd,
};
}
이제 훅을 만들어주면 준비는 거의 끝났습니다.
alert
, confirm
, prompt
를 호출하면 제목 등을 설정한 뒤 Promise
를 반환하며, 창이 닫힐 때 onInteractionEnd
를 호출하도록 만들 겁니다.
import { memo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import useDialogStore from "../store/useDialogStore";
import useDialog from "../hooks/useDialog";
import "./Dialog.css";
export default function Dialog() {
const dialogRoot = document.getElementById("dialog") as HTMLElement;
const inputRef = useRef<HTMLInputElement>(null);
const { revealed, title, description, type } = useDialogStore();
const { onInteractionEnd } = useDialog();
const handleConfirmClick = useCallback(() => {
if (type === "prompt") {
onInteractionEnd(inputRef.current?.value || "");
return;
}
onInteractionEnd(true);
}, [inputRef.current, type, onInteractionEnd]);
const handleCancelClick = useCallback(() => {
if (type === "prompt") {
onInteractionEnd("");
return;
}
onInteractionEnd(false);
}, [type, onInteractionEnd]);
const DialogComponent = memo(() => (
<>
<div className="dialog-backdrop" onClick={handleCancelClick} />
<section className="dialog">
<h2 className="dialog__title">{title}</h2>
{description && (
<p className="dialog__description">{description}</p>
)}
{type === "prompt" && (
<form onSubmit={handleConfirmClick}>
<input
autoFocus
type="text"
className="dialog__input"
ref={inputRef}
/>
</form>
)}
<div className="dialog__buttons">
<button
type="button"
className="dialog__button dialog__button--confirm"
onClick={handleConfirmClick}
>
OK
</button>
{type !== "alert" && (
<button
type="button"
className="dialog__button dialog__button--cancel"
onClick={handleCancelClick}
>
Cancel
</button>
)}
</div>
</section>
</>
));
return createPortal(revealed ? <DialogComponent /> : null, dialogRoot);
}
type이 alert일 땐 확인만 뜨게 하는 등 각 상황에 맞게 마크업을 짜줬습니다.
굳이 상태를 만들 필요는 없을 것 같아 input
은 useRef
로 관리하여, 창이 닫힐 때 그 값을 꺼내오도록 하였습니다.
<div id="dialog"></div>
포탈을 열었으니 #dialog
를 추가해주는 것도 잊으면 안 됩니다.
.dialog-backdrop {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
--dialog-primary: #e57373;
position: fixed;
top: 50%;
left: 50%;
width: clamp(0px, 90vw, 400px);
padding: 16px;
background-color: #fff;
transform: translate3d(-50%, -50%, 0);
box-sizing: border-box;
border-radius: 8px;
line-height: 1.5;
}
.dialog__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: bold;
line-height: 1.5;
}
.dialog__description {
margin: 0.5rem 0;
line-height: 1.3;
}
.dialog__input {
display: block;
width: 100%;
margin: 0.5rem 0;
padding: 0.4rem 0.8rem;
border: 2px solid #121212;
border-radius: 0.3rem;
outline: 0;
font-size: 1.1rem;
box-sizing: border-box;
}
.dialog__input:focus {
border-color: var(--dialog-primary);
}
.dialog__buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.dialog__button {
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.dialog__button--confirm {
border: 1px solid var(--dialog-primary);
background-color: var(--dialog-primary);
}
.dialog__button--cancel {
border: 1px solid #e1e1e1;
background-color: #fff;
color: var(--dialog-primary);
}
@media (prefers-color-scheme: dark) {
.dialog {
--dialog-primary: #ffcdd2;
background-color: #121212;
color: #f1f1f1;
}
.dialog__input {
background-color: #121212;
color: #f1f1f1;
border-color: #f1f1f1;
}
.dialog__button--confirm {
color: #121212;
}
.dialog__button--cancel {
border: 1px solid #4f4f4f;
background-color: #121212;
color: var(--dialog-primary);
}
}
너무 밋밋하지만 않게 디자인해뒀습니다.
마크업에서도 확인할 수 있지만, 조금 더 창에 집중할 수 있도록 배경을 어둡게 만드는 작업도 진행했습니다.
prefers-color-scheme
은 여기서만 작업해두면 창이 뜨는 페이지와 어울리지 않을 수 있어 작업하는 게 맞을지 고민을 잠깐 했는데, 잠깐 고민해보니 ua가 띄우는 창도 그냥 사용자가 선호하는 색상에 따라 배경색이 결정되니 큰 상관이 없지 않을까 싶어 작업해뒀습니다.
import { useCallback, useState } from "react";
import useDialog from "./hooks/useDialog";
export default function App() {
const { confirm, alert, prompt } = useDialog();
const [message, setMessage] = useState("");
const showConfirm = useCallback(async () => {
const confirmed = await confirm(
"Are you sure?",
"This can't be undone."
);
setMessage(confirmed ? "Sure!" : "Nope.");
}, []);
const showAlert = useCallback(async () => {
await alert("Hello there!");
setMessage("Will update after alert");
}, []);
const showPrompt = useCallback(async () => {
const inputted = await prompt("What's your name?");
setMessage(`Your name is ${inputted}`);
}, []);
return (
<div style={{ textAlign: "center" }}>
<button type="button" onClick={showConfirm}>
Confirm
</button>
<button type="button" onClick={showAlert}>
Alert
</button>
<button type="button" onClick={showPrompt}>
Prompt
</button>
<h1>{message}</h1>
</div>
);
}
이제 끝입니다!
비록 매번 async
함수를 만들고 await
을 매번 걸어줘야 한다는 게 안타깝긴 하지만, 디자인적으로 훨씬 자유롭게 컴포넌트들을 활용할 수 있으니 충분히 그만한 가치가 있지 않나 싶습니다.