배너 이미지

React에서 dialog 만들기

최종 수정일 : (10개월 전)

바쁘다는 핑계로 너무 오랜만에 글을 올리는 것 같네요. 😥

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 중 resolveresponseHandler에 담을 예정입니다.

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일 땐 확인만 뜨게 하는 등 각 상황에 맞게 마크업을 짜줬습니다.

굳이 상태를 만들 필요는 없을 것 같아 inputuseRef로 관리하여, 창이 닫힐 때 그 값을 꺼내오도록 하였습니다.

<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을 매번 걸어줘야 한다는 게 안타깝긴 하지만, 디자인적으로 훨씬 자유롭게 컴포넌트들을 활용할 수 있으니 충분히 그만한 가치가 있지 않나 싶습니다.


profile

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

주의 : 비밀 댓글 사용 시 수정 기능을 이용할 수 있는 시간이 지나면 작성자도 내용 확인이 불가능합니다.