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

【Next.js】管理画面のDataTableで使える行アクションメニューを作成

    こんにちは、ライトです。
    前回までに、管理画面で使う一覧まわりのコンポーネントを作成してきました。

    これまでに作成したものは、以下のようなものです。

    • 管理画面レイアウト

    • StatusBadge

    • DataTable

    • Pagination

    • SearchFilterBar

    ここまでで、管理画面の一覧として、

    • データを表示する

    • ステータスを表示する

    • ページを切り替える

    • 検索・絞り込みを行う

    ところまでは作れました。

    次に必要になるのは、一覧の各行に対する操作です。

    管理画面では、ユーザー一覧や商品一覧などで、行ごとに次のような操作を行うことがよくあります。

    • 詳細を見る

    • 編集する

    • 削除する

    • 停止する

    • 再送信する

    これらのボタンをすべて横に並べると、テーブルの幅を取りすぎてしまいます。

    そこで今回は、三点リーダーのボタンを押すと操作メニューが開く、行アクションメニューを作成します。


    今回作成するもの

    今回は、以下のように使える RowActionMenu コンポーネントを作成します。

    <RowActionMenu
      actions={[
        {
          label: "詳細を見る",
          onClick: () => console.log("詳細"),
        },
        {
          label: "編集する",
          onClick: () => console.log("編集"),
        },
        {
          label: "削除する",
          variant: "danger",
          onClick: () => console.log("削除"),
        },
      ]}
    />

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

    • 三点リーダーボタンを表示する

    • クリックでメニューを開く

    • メニュー項目をpropsで渡せる

    • danger表示に対応する

    • disabled状態に対応する

    • 外側クリックで閉じる

    • Escキーで閉じる

    • 項目クリック後に閉じる

    • DataTableのセル内で使える

    削除のような危険な操作は、variant: "danger" を指定して赤系の表示にします。


    完成形

    以下のような行アクションメニューを作成します。

    画像
    アクションメニューを追加した一覧

    DataTableの右端に三点リーダーボタンを置き、クリックすると行ごとの操作メニューを表示します。


    ファイル構成

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

    components/
      ui/
        RowActionMenu.tsx
    
    app/
      page.tsx

    RowActionMenu は、管理画面のいろいろな一覧で使えるUIなので、components/ui/ に作成します。


    RowActionMenuで実装する機能

    今回の RowActionMenu では、メニュー項目を actions として渡します。

    export type RowAction = {
      label: string;
      onClick: () => void;
      variant?: "default" | "danger";
      disabled?: boolean;
    };

    このようにしておくと、一覧ごとに必要な操作を自由に渡せます。

    たとえば、ユーザー一覧では次のような操作にできます。

    <RowActionMenu
      actions={[
        { label: "詳細を見る", onClick: handleShow },
        { label: "編集する", onClick: handleEdit },
        { label: "削除する", variant: "danger", onClick: handleDelete },
      ]}
    />

    商品一覧なら、別の操作にできます。

    <RowActionMenu
      actions={[
        { label: "編集する", onClick: handleEdit },
        { label: "在庫を確認", onClick: handleStock },
        { label: "販売停止", variant: "danger", onClick: handleStop },
      ]}
    />

    RowActionMenu.tsxを作成する

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

    components/ui/RowActionMenu.tsx

    "use client";
    
    import { useEffect, useRef, useState } from "react";
    
    type RowActionVariant = "default" | "danger";
    
    export type RowAction = {
      label: string;
      onClick: () => void;
      variant?: RowActionVariant;
      disabled?: boolean;
    };
    
    type RowActionMenuProps = {
      actions: RowAction[];
      label?: string;
    };
    
    export default function RowActionMenu({
      actions,
      label = "行の操作",
    }: RowActionMenuProps) {
      const [isOpen, setIsOpen] = useState(false);
      const menuRef = useRef<HTMLDivElement | null>(null);
    
      useEffect(() => {
        if (!isOpen) {
          return;
        }
    
        const handleClickOutside = (event: MouseEvent) => {
          if (!menuRef.current) {
            return;
          }
    
          if (!menuRef.current.contains(event.target as Node)) {
            setIsOpen(false);
          }
        };
    
        const handleKeyDown = (event: KeyboardEvent) => {
          if (event.key === "Escape") {
            setIsOpen(false);
          }
        };
    
        document.addEventListener("mousedown", handleClickOutside);
        window.addEventListener("keydown", handleKeyDown);
    
        return () => {
          document.removeEventListener("mousedown", handleClickOutside);
          window.removeEventListener("keydown", handleKeyDown);
        };
      }, [isOpen]);
    
      const handleActionClick = (action: RowAction) => {
        if (action.disabled) {
          return;
        }
    
        action.onClick();
        setIsOpen(false);
      };
    
      return (
        <div ref={menuRef} className="relative inline-flex justify-end">
          <button
            type="button"
            onClick={() => setIsOpen((current) => !current)}
            className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
            aria-label={label}
            aria-haspopup="menu"
            aria-expanded={isOpen}
          >
            <span className="sr-only">{label}</span>
            <span className="text-lg leading-none">…</span>
          </button>
    
          {isOpen && (
            <div
              role="menu"
              className="absolute top-10 right-0 z-20 w-40 overflow-hidden rounded-lg border border-slate-200 bg-white py-1 shadow-lg shadow-slate-900/10"
            >
              {actions.map((action) => {
                const isDanger = action.variant === "danger";
    
                return (
                  <button
                    key={action.label}
                    type="button"
                    role="menuitem"
                    disabled={action.disabled}
                    onClick={() => handleActionClick(action)}
                    className={[
                      "block w-full px-4 py-2 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-40",
                      isDanger
                        ? "text-rose-600 hover:bg-rose-50"
                        : "text-slate-700 hover:bg-slate-50",
                    ].join(" ")}
                  >
                    {action.label}
                  </button>
                );
              })}
            </div>
          )}
        </div>
      );
    }

    これで、行アクションメニューの基本形ができました。


    actionsをpropsで渡す

    メニュー項目は、actions として渡します。

    export type RowAction = {
      label: string;
      onClick: () => void;
      variant?: RowActionVariant;
      disabled?: boolean;
    };

    label はメニューに表示する文字です。

    label: "編集する"

    onClick はクリック時に実行する処理です。

    onClick: () => console.log("編集")

    variant は見た目の種類です。
    削除のような危険な操作には danger を指定します。

    variant: "danger"

    disabled を指定すると、メニュー項目を無効化できます。

    disabled: true

    この形にしておくと、テーブルごとに自由な操作メニューを作れます。


    三点リーダーボタンを作成する

    メニューを開くボタンには、三点リーダーを表示しています。

    <button
      type="button"
      onClick={() => setIsOpen((current) => !current)}
      className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
      aria-label={label}
      aria-haspopup="menu"
      aria-expanded={isOpen}
    >
      <span className="sr-only">{label}</span>
      <span className="text-lg leading-none">…</span>
    </button>

    ボタンを押すと、isOpen を切り替えます。

    onClick={() => setIsOpen((current) => !current)}

    aria-haspopup と aria-expanded も付けています。

    aria-haspopup="menu"
    aria-expanded={isOpen}

    これにより、メニューを開くボタンであることや、現在開いているかどうかを伝えやすくなります。


    danger表示に対応する

    削除のような危険な操作は、赤系の表示にします。

    const isDanger = action.variant === "danger";

    表示クラスを切り替えます。

    isDanger
      ? "text-rose-600 hover:bg-rose-50"
      : "text-slate-700 hover:bg-slate-50"

    使用例は以下です。

    <RowActionMenu
      actions={[
        {
          label: "削除する",
          variant: "danger",
          onClick: () => console.log("削除"),
        },
      ]}
    />

    管理画面では、削除や停止のような操作は慎重に扱う必要があります。

    そのため、危険な操作は色で区別できるようにしておくと分かりやすくなります。


    外側クリックで閉じる

    メニューが開いている状態で外側をクリックしたら、メニューを閉じるようにしています。

    const handleClickOutside = (event: MouseEvent) => {
      if (!menuRef.current) {
        return;
      }
    
      if (!menuRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    };

    menuRef の中にクリック対象が含まれていなければ、メニューの外側をクリックしたと判断します。

    document.addEventListener("mousedown", handleClickOutside);

    メニューを閉じるときには、イベントリスナーを解除します。

    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
    };

    これにより、不要なイベントが残らないようにしています。


    Escキーで閉じる

    Escキーでも閉じられるようにしています。

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        setIsOpen(false);
      }
    };

    メニューが開いている間だけ、keydown イベントを登録します。

    window.addEventListener("keydown", handleKeyDown);

    外側クリックと同じく、クリーンアップも行います。

    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };

    モーダルやメニューのようなUIでは、Escキーで閉じられると操作しやすくなります。


    項目クリック後に閉じる

    メニュー項目をクリックしたら、処理を実行してからメニューを閉じます。

    const handleActionClick = (action: RowAction) => {
      if (action.disabled) {
        return;
      }
    
      action.onClick();
      setIsOpen(false);
    };

    disabled の場合は何もしません。

    if (action.disabled) {
      return;
    }

    有効な項目の場合は、onClick を実行してから閉じます。

    action.onClick();
    setIsOpen(false);

    DataTableに組み込む

    次に、前回作成した DataTable に組み込んで使います。

    ユーザー一覧の列定義に、操作列を追加します。

    import RowActionMenu from "@/components/ui/RowActionMenu";

    columns に actions 列を追加します。

    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: () => {
                  console.log("削除:", user.id);
                },
              },
            ]}
          />
        ),
      },
    ];

    これで、テーブルの右端に行ごとのアクションメニューを表示できます。


    操作列の考え方

    操作列は、ヘッダーを空にしています。

    {
        key: "actions",
        header: "",
        className: "w-16 text-right",
    }

    操作ボタンだけを表示する列なので、ヘッダー名は空で問題ありません。

    幅は少し狭めにしています。

    これにより、テーブルの右端にコンパクトに配置できます。


    実装のポイント

    今回のポイントは、DataTable 本体に操作ロジックを持たせないことです。

    DataTable は、あくまでテーブルを表示するだけにしています。

    行ごとの操作は、列定義の render の中で自由に指定します。

    {
      key: "actions",
      header: "",
      render: (user) => (
        <RowActionMenu actions={[...]} />
      ),
    }

    この形にしておくと、ユーザー一覧、商品一覧、注文一覧などで、必要な操作だけを渡せます。

    たとえば、ユーザー一覧では以下のようにできます。

    • 詳細を見る

    • 編集する

    • 削除する

    商品一覧では、次のように変えられます。

    • 編集する

    • 在庫を見る

    • 販売停止

    DataTable を変更せずに、列定義だけで操作メニューを変えられるのがメリットです。


    まとめ

    今回は、管理画面の DataTable で使える RowActionMenu コンポーネントを作成しました。

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

    • 三点リーダーボタン

    • クリックで開くメニュー

    • actionsによる項目指定

    • danger表示

    • disabled状態

    • 外側クリックで閉じる

    • Escキーで閉じる

    • DataTableへの組み込み

    管理画面では、一覧の各行に対して操作を行う場面が多くあります。

    RowActionMenu を作っておくと、詳細、編集、削除などの操作をコンパクトにまとめられます。

    また、DataTable の columns に組み込める形にしたことで、テーブル本体を変更せずに行ごとの操作を追加できます。

    この行アクションメニューから削除確認ダイアログを表示し、削除後にトースト通知を出す流れを作ると、さらに実務に近い管理画面になりそうです。

    #NextJS #React #コンポーネント #管理画面 #アクション #メニュー


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

    あなたへのおすすめ