メインコンテンツへスキップ
見出し画像

【Next.js】管理画面で使えるConfirmModalを作成

    こんにちは、ライトです。
    これまで、管理画面で使うコンポーネントとして、以下のようなものを作成してきました。

    • 管理画面レイアウト

    • StatusBadge

    • DataTable

    • Pagination

    • SearchFilterBar

    • RowActionMenu

    前回は、DataTable の各行で使える RowActionMenu を作成しました。

    ただ、削除のような操作は、クリックした瞬間に実行するのは少し危険です。

    管理画面では、確認を挟みたい場面がよくあります。

    そこで今回は、削除確認や更新確認などで使える、汎用的な ConfirmModal を作成します。


    以前作成したModalとの違い

    以前、汎用的な Modal コンポーネントを作成しました。

    Modal は、自由な内容を表示するための「枠」として使うものでした。

    <Modal isOpen={isOpen} onClose={onClose} title="詳細">
      <p>ここに自由な内容を表示します。</p>
    </Modal>

    詳細表示、フォーム、画像プレビュー、お知らせなど、幅広い用途に使えるのが Modal の役割です。

    一方で、今回作成する ConfirmModal は、ユーザーに確認してから処理を実行するための専用コンポーネントです。

    用途はもう少し限定されます。

    • 削除確認

    • 更新確認

    • 公開確認

    • 停止確認

    • 送信確認

    つまり、役割としては次のように分けます。

    • Modal  
      汎用的な表示枠

    • ConfirmModal  
      確認操作専用のモーダル

    ConfirmModal では、タイトル、説明文、キャンセルボタン、実行ボタン、危険操作の見た目、ローディング状態などをあらかじめ持たせます。

    自由度よりも、確認操作としての分かりやすさを優先します。


    今回作成するもの

    今回は、以下のように使える ConfirmModal を作成します。

    <ConfirmModal
      isOpen={isOpen}
      title="ユーザーを削除しますか?"
      description="この操作は取り消せません。"
      confirmLabel="削除する"
      cancelLabel="キャンセル"
      variant="danger"
      isLoading={isDeleting}
      onConfirm={handleDelete}
      onClose={closeModal}
    />

    削除だけでなく、更新確認にも使えるようにします。

    <ConfirmModal
      isOpen={isOpen}
      title="変更内容を保存しますか?"
      description="入力した内容で情報を更新します。"
      confirmLabel="保存する"
      variant="default"
      isLoading={isSaving}
      onConfirm={handleSave}
      onClose={closeModal}
    />

    今回の ConfirmModal では、以下に対応します。

    • タイトルを表示する

    • 説明文を表示する

    • childrenで補足情報を表示できる

    • キャンセルボタンを表示する

    • 実行ボタンを表示する

    • danger表示に対応する

    • loading状態に対応する

    • 背景クリックで閉じる

    • Escキーで閉じる

    • loading中は閉じない


    完成形

    以下のような確認モーダルを作成します。

    画像
    削除確認のダイアログ

    削除確認の場合は赤系、更新確認の場合はシアン系のボタンで表示します。


    ファイル構成

    今回は、以下の構成で作成します。

    components/
      ui/
        ConfirmModal.tsx
    
    app/
      page.tsx

    ConfirmModal は、管理画面内のさまざまな操作で使える汎用UIなので、components/ui/ に作成します。


    ConfirmModalのprops設計

    まず、propsを考えます。

    type ConfirmModalVariant = "default" | "danger";
    
    type ConfirmModalProps = {
      isOpen: boolean;   # モーダルを表示しているかどうか
      title: string;     # 確認内容のタイトル
      description?: string;  # 補足説明
      confirmLabel?: string; # 実行ボタンの文言
      cancelLabel?: string;  # キャンセルボタンの文言
      variant?: ConfirmModalVariant;  # default もしくは danger
      isLoading?: boolean;   # 実行中かどうか
      children?: React.ReactNode;     # 削除対象などの補足表示
      onConfirm: () => void; # 実行ボタンを押したときの処理
      onClose: () => void;   # 閉じる処理
    };

    variant は、通常操作と危険操作を分けるために使います。

    • default  
      保存、更新、公開など

    • danger  
      削除、停止、取り消しなど


    ConfirmModal.tsxを作成する

    それでは、ConfirmModal.tsx を作成します。

    components/ui/ConfirmModal.tsx

    "use client";
    
    import { useEffect } from "react";
    
    type ConfirmModalVariant = "default" | "danger";
    
    type ConfirmModalProps = {
      isOpen: boolean;
      title: string;
      description?: string;
      confirmLabel?: string;
      cancelLabel?: string;
      variant?: ConfirmModalVariant;
      isLoading?: boolean;
      children?: React.ReactNode;
      onConfirm: () => void;
      onClose: () => void;
    };
    
    const confirmButtonClasses: Record<ConfirmModalVariant, string> = {
      default: "bg-primary-500 text-white hover:bg-primary-700",
      danger: "bg-rose-600 text-white hover:bg-rose-700",
    };
    
    const iconClasses: Record<ConfirmModalVariant, string> = {
      default: "border-primary-100 bg-primary-50 text-primary-600",
      danger: "border-rose-100 bg-rose-50 text-rose-600",
    };
    
    export default function ConfirmModal({
      isOpen,
      title,
      description,
      confirmLabel = "実行する",
      cancelLabel = "キャンセル",
      variant = "default",
      isLoading = false,
      children,
      onConfirm,
      onClose,
    }: ConfirmModalProps) {
      useEffect(() => {
        if (!isOpen) {
          return;
        }
    
        const handleKeyDown = (event: KeyboardEvent) => {
          if (event.key === "Escape" && !isLoading) {
            onClose();
          }
        };
    
        window.addEventListener("keydown", handleKeyDown);
    
        return () => {
          window.removeEventListener("keydown", handleKeyDown);
        };
      }, [isOpen, isLoading, onClose]);
    
      useEffect(() => {
        if (!isOpen) {
          return;
        }
    
        const previousOverflow = document.body.style.overflow;
        document.body.style.overflow = "hidden";
    
        return () => {
          document.body.style.overflow = previousOverflow;
        };
      }, [isOpen]);
    
      if (!isOpen) {
        return null;
      }
    
      const handleClose = () => {
        if (isLoading) {
          return;
        }
    
        onClose();
      };
    
      return (
        <div className="fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
          <button
            type="button"
            className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
            onClick={handleClose}
            aria-label="モーダルを閉じる"
          />
    
          <div
            role="dialog"
            aria-modal="true"
            aria-labelledby="confirm-modal-title"
            className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/10"
          >
            <div className="flex gap-4">
              <div
                className={[
                  "flex h-11 w-11 shrink-0 items-center justify-center rounded-full border",
                  iconClasses[variant],
                ].join(" ")}
                aria-hidden
              >
                <span className="text-lg font-semibold">
                  {variant === "danger" ? "!" : "✓"}
                </span>
              </div>
    
              <div className="min-w-0 flex-1">
                <h2
                  id="confirm-modal-title"
                  className="text-lg font-semibold tracking-tight text-slate-950"
                >
                  {title}
                </h2>
    
                {description && (
                  <p className="mt-2 text-sm leading-6 text-slate-600">
                    {description}
                  </p>
                )}
              </div>
            </div>
    
            {children && <div className="mt-5">{children}</div>}
    
            <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
              <button
                type="button"
                onClick={handleClose}
                disabled={isLoading}
                className="h-11 rounded-lg border border-slate-200 bg-white px-4 text-sm font-medium text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
              >
                {cancelLabel}
              </button>
    
              <button
                type="button"
                onClick={onConfirm}
                disabled={isLoading}
                className={[
                  "h-11 rounded-lg px-4 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-70",
                  confirmButtonClasses[variant],
                ].join(" ")}
              >
                {isLoading ? "処理中..." : confirmLabel}
              </button>
            </div>
          </div>
        </div>
      );
    }

    これで、確認操作に使える ConfirmModal ができました。


    danger表示に対応する

    variant によって、実行ボタンやアイコンの色を切り替えています。

    type ConfirmModalVariant = "default" | "danger";

    ボタンの色は以下のように定義しています。

    const confirmButtonClasses: Record<ConfirmModalVariant, string> = {
      default: "bg-primary-500 text-white hover:bg-primary-700",
      danger: "bg-rose-600 text-white hover:bg-rose-700",
    };

    削除確認の場合は、variant="danger" を指定します。

    <ConfirmModal
      variant="danger"
      confirmLabel="削除する"
      ...
    />

    これにより、削除ボタンが赤系になります。

    • 削除

    • 停止

    • 取り消し

    のような危険な操作では、通常操作と見た目を分けると分かりやすくなります。


    childrenで補足情報を表示する

    ConfirmModal では、children を使えるようにしています。

    {children && <div className="mt-5">{children}</div>}

    これにより、削除対象や更新内容などを自由に表示できます。

    たとえば、削除確認では次のように使えます。

    <ConfirmModal
      isOpen={isOpen}
      title="ユーザーを削除しますか?"
      description="この操作は取り消せません。"
      confirmLabel="削除する"
      variant="danger"
      onConfirm={handleDelete}
      onClose={closeModal}
    >
      <div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
        削除対象:山田 太郎
      </div>
    </ConfirmModal>

    削除対象を明示しておくと、誤操作を防ぎやすくなります。


    loading状態に対応する

    削除や更新の処理は、API通信になることが多いです。

    そのため、処理中の状態を isLoading で渡せるようにしています。

    isLoading?: boolean;

    処理中は、ボタンを無効化します。

    disabled={isLoading}

    実行ボタンの文言も切り替えます。

    {isLoading ? "処理中..." : confirmLabel}

    削除中や更新中に何度もボタンを押せると、二重実行につながる可能性があります。

    そのため、処理中はボタンをdisabledにしておくと安心です。


    loading中は閉じない

    今回の実装では、isLoading 中はモーダルを閉じないようにしています。

    const handleClose = () => {
      if (isLoading) {
        return;
      }
    
      onClose();
    };

    背景クリックでも、handleClose を使っています。

    <button
      type="button"
      className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
      onClick={handleClose}
      aria-label="モーダルを閉じる"
    />

    Escキーでも、isLoading 中は閉じないようにしています。

    if (event.key === "Escape" && !isLoading) {
      onClose();
    }

    削除中にモーダルを閉じられると、処理が成功したのか失敗したのか分かりにくくなることがあります。

    そのため、処理中は閉じられないようにしています。


    背景スクロールを止める

    モーダル表示中は、背景がスクロールしないようにしています。

    useEffect(() => {
      if (!isOpen) {
        return;
      }
    
      const previousOverflow = document.body.style.overflow;
      document.body.style.overflow = "hidden";
    
      return () => {
        document.body.style.overflow = previousOverflow;
      };
    }, [isOpen]);

    モーダルの背後にある画面がスクロールできると、操作感が悪くなることがあります。

    そのため、モーダル表示中だけ body の overflow を hidden にしています。

    閉じるときには、元の状態に戻します。


    RowActionMenuからConfirmModalを開く

    次に、前回作成した RowActionMenu から ConfirmModal を開くようにします。

    ユーザー一覧を例にします。

    app/page.tsx

    "use client";
    
    import { useState } from "react";
    import DashboardLayout from "@/components/dashboard/DashboardLayout";
    import ConfirmModal from "@/components/ui/ConfirmModal";
    import DataTable, { DataTableColumn } from "@/components/ui/DataTable";
    import RowActionMenu from "@/components/ui/RowActionMenu";
    import StatusBadge from "@/components/ui/StatusBadge";
    
    type User = {
      id: number;
      name: string;
      email: string;
      role: "admin" | "editor" | "viewer";
      status: "active" | "pending" | "suspended";
    };
    
    const initialUsers: User[] = [
      {
        id: 1,
        name: "山田 太郎",
        email: "yamada@example.com",
        role: "admin",
        status: "active",
      },
      {
        id: 2,
        name: "佐藤 花子",
        email: "sato@example.com",
        role: "editor",
        status: "pending",
      },
      {
        id: 3,
        name: "田中 一郎",
        email: "tanaka@example.com",
        role: "viewer",
        status: "suspended",
      },
    ];
    
    function renderStatus(status: User["status"]) {
      switch (status) {
        case "active":
          return <StatusBadge variant="success">有効</StatusBadge>;
        case "pending":
          return <StatusBadge variant="warning">承認待ち</StatusBadge>;
        case "suspended":
          return <StatusBadge variant="danger">停止中</StatusBadge>;
      }
    }
    
    function renderRole(role: User["role"]) {
      switch (role) {
        case "admin":
          return "管理者";
        case "editor":
          return "編集者";
        case "viewer":
          return "閲覧者";
      }
    }
    
    export default function Page() {
      const [users, setUsers] = useState<User[]>(initialUsers);
      const [selectedUser, setSelectedUser] = useState<User | null>(null);
      const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
      const [isDeleting, setIsDeleting] = useState(false);
    
      const openDeleteModal = (user: User) => {
        setSelectedUser(user);
        setIsDeleteModalOpen(true);
      };
    
      const closeDeleteModal = () => {
        if (isDeleting) {
          return;
        }
    
        setIsDeleteModalOpen(false);
        setSelectedUser(null);
      };
    
      const handleDelete = async () => {
        if (!selectedUser) {
          return;
        }
    
        setIsDeleting(true);
    
        await new Promise((resolve) => setTimeout(resolve, 800));
    
        setUsers((currentUsers) =>
          currentUsers.filter((user) => user.id !== selectedUser.id)
        );
    
        setIsDeleting(false);
        setIsDeleteModalOpen(false);
        setSelectedUser(null);
      };
    
      const columns: DataTableColumn<User>[] = [
        {
          key: "name",
          header: "名前",
          render: (user) => (
            <div>
              <div className="font-medium text-slate-900">{user.name}</div>
              <div className="mt-1 text-xs text-slate-400">ID: {user.id}</div>
            </div>
          ),
        },
        {
          key: "email",
          header: "メールアドレス",
          render: (user) => (
            <span className="text-slate-600">{user.email}</span>
          ),
        },
        {
          key: "role",
          header: "権限",
          render: (user) => (
            <span className="text-slate-600">{renderRole(user.role)}</span>
          ),
        },
        {
          key: "status",
          header: "ステータス",
          render: (user) => renderStatus(user.status),
        },
        {
          key: "actions",
          header: "",
          className: "w-16 text-right",
          render: (user) => (
            <RowActionMenu
              label={`${user.name}の操作`}
              actions={[
                {
                  label: "詳細を見る",
                  onClick: () => {
                    console.log("詳細:", user.id);
                  },
                },
                {
                  label: "編集する",
                  onClick: () => {
                    console.log("編集:", user.id);
                  },
                },
                {
                  label: "削除する",
                  variant: "danger",
                  onClick: () => {
                    openDeleteModal(user);
                  },
                },
              ]}
            />
          ),
        },
      ];
    
      return (
        <DashboardLayout>
          <div className="space-y-6">
            <section>
              <p className="text-sm font-medium text-primary-700">Users</p>
              <h2 className="mt-1 text-2xl font-semibold text-slate-950">
                ユーザー一覧
              </h2>
              <p className="mt-2 max-w-2xl text-sm leading-7 text-slate-600">
                RowActionMenuからConfirmModalを開き、削除確認を行うサンプルです。
              </p>
            </section>
    
            <DataTable
              columns={columns}
              data={users}
              getRowKey={(user) => user.id}
              emptyMessage="ユーザーが見つかりません。"
            />
    
            <ConfirmModal
              isOpen={isDeleteModalOpen}
              title="ユーザーを削除しますか?"
              description="この操作は取り消せません。削除すると、ユーザー情報は一覧から削除されます。"
              confirmLabel="削除する"
              cancelLabel="キャンセル"
              variant="danger"
              isLoading={isDeleting}
              onConfirm={handleDelete}
              onClose={closeDeleteModal}
            >
              {selectedUser && (
                <div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
                  <div className="font-medium text-slate-900">
                    {selectedUser.name}
                  </div>
                  <div className="mt-1 text-slate-500">
                    {selectedUser.email}
                  </div>
                </div>
              )}
            </ConfirmModal>
          </div>
        </DashboardLayout>
      );
    }

    これで、行アクションメニューから削除確認モーダルを開けるようになります。


    削除確認の流れ

    今回の削除確認の流れは以下です。

    1. RowActionMenuの「削除する」をクリック

    2. selectedUserに対象ユーザーをセット

    3. ConfirmModalを開く

    4. 「削除する」をクリック

    5. 削除処理を実行

    6. 一覧から対象ユーザーを削除

    7. ConfirmModalを閉じる

    削除対象は、selectedUser として管理しています。

    const [selectedUser, setSelectedUser] = useState<User | null>(null);

    削除ボタンを押したときに、対象ユーザーをセットします。

    const openDeleteModal = (user: User) => {
      setSelectedUser(user);
      setIsDeleteModalOpen(true);
    };

    削除処理では、対象ユーザーを一覧から取り除いています。

    setUsers((currentUsers) =>
      currentUsers.filter((user) => user.id !== selectedUser.id)
    );

    更新確認にも使える

    ConfirmModal は削除専用ではありません。

    variant="default" を使えば、更新確認や公開確認にも使えます。

    <ConfirmModal
      isOpen={isUpdateModalOpen}
      title="変更内容を保存しますか?"
      description="入力した内容で情報を更新します。"
      confirmLabel="保存する"
      cancelLabel="キャンセル"
      variant="default"
      isLoading={isSaving}
      onConfirm={handleSave}
      onClose={closeUpdateModal}
    />

    公開確認にも使えます。

    <ConfirmModal
      isOpen={isPublishModalOpen}
      title="記事を公開しますか?"
      description="公開すると、ユーザーがこの記事を閲覧できるようになります。"
      confirmLabel="公開する"
      variant="default"
      onConfirm={handlePublish}
      onClose={closePublishModal}
    />

    このように、削除だけでなく、確認が必要な操作全般に使えるようにしています。


    実装のポイント

    今回のポイントは、ConfirmModal を削除専用にしすぎず、確認操作全般に使えるようにしたことです。

    削除や確認といった操作に対して、同じUIで確認を挟めます。

    ConfirmModal では、タイトル、説明文、実行ボタン、キャンセルボタン、loading状態を標準で持たせています。

    そのため、確認ダイアログとしての見た目や操作を画面ごとに書き直さなくて済みます。


    まとめ

    今回は、管理画面で使える ConfirmModal を作成しました。

    以前作成した Modal は、自由な内容を表示するための汎用的な枠でした。

    一方、今回の ConfirmModal は、削除確認や更新確認など、ユーザーに判断を求める操作に特化したコンポーネントです。

    今回の実装では、以下に対応しました。

    • 削除確認

    • 更新確認

    • danger表示

    • loading状態

    • 背景クリックで閉じる

    • Escキーで閉じる

    • loading中は閉じない

    • childrenで補足情報を表示する

    • RowActionMenuから開く

    管理画面では、削除や更新などの操作が多くあります。

    ConfirmModal を用意しておくと、危険な操作や確認が必要な操作を安全に扱いやすくなります。

    削除後にトースト通知を表示したり、API通信と組み合わせたりすると、さらに実務に近い管理画面にできそうです。

    #NextJS #React #コンポーネント #確認 #モーダル #ダイアログ #削除 #更新


     
     
    主にバックエンドの開発をしています。 AIを業務に使いながら、「もっと早く知りたかった」と思うことが山ほど出てきました。 そのリアルな気づきや失敗も含めて、エンジニアに届けたくてnoteを始めました。 現場目線の発信を目指します。 https://code-light.jp/

    あなたへのおすすめ