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

【Next.js】管理画面で使えるページネーションコンポーネントを作成

    こんにちは、ライトです。
    前回は、管理画面で使える DataTable コンポーネントを作成しました。

    列定義を columns として渡し、データを data として渡すことで、ユーザー一覧や顧客一覧のような一覧画面を作りやすくしました。

    ただ、実際の管理画面では、データが数件だけとは限りません。

    たとえば、ユーザー一覧、顧客一覧、商品一覧、注文一覧などでは、データが数十件、数百件になることがあります。

    そのような場合、すべてのデータを一度に表示すると画面が見づらくなります。

    そこで今回は、管理画面で使える ページネーションコンポーネント を作成します。


    管理画面でページネーションが必要になる場面

    管理画面では、一覧表示を扱う場面が多くあります。

    • ユーザー一覧

    • 顧客一覧

    • 商品一覧

    • 注文一覧

    • 売上一覧

    • 操作ログ一覧

    このような一覧画面では、データ件数が多くなりやすいです。

    データが多い場合、ページネーションがあると次のようなメリットがあります。

    • 表示件数を絞れる

    • 現在どの範囲を見ているか分かる

    • 前後のページへ移動しやすい

    • 一覧画面の表示が重くなりにくい

    特に管理画面では、ただページ番号を表示するだけでなく、

    全120件中 1〜10件を表示

    のように、現在表示している件数の範囲も見せると分かりやすくなります。


    今回作成するもの

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

    <Pagination
      currentPage={page}
      totalPages={12}
      totalItems={120}
      pageSize={10}
      pageSizeOptions={[10, 20, 50]}
      onPageChange={(page) => setPage(page)}
      onPageSizeChange={(size) => setPageSize(size)}
    />

    対応する機能は以下のものとなります。

    • 現在ページを表示する

    • 総ページ数を表示する

    • 前へ・次へボタンを表示する

    • ページ番号を表示する

    • ページ数が多い場合は省略記号を表示する

    • 全件数と現在の表示範囲を表示する

    • 表示件数を切り替えられる

    • モバイルでは簡略表示にする

    PCでは、件数表示、表示件数セレクト、ページ番号、前後ボタンを表示します。

    モバイルでは、横幅が狭いため、ページ番号一覧は出さずに、シンプルに表示します。


    完成形

    以下のようなページネーションを作成します。

    画像
    PCでの表示
    画像
    モバイルでの表示

    PCではページ番号を表示し、モバイルでは現在ページと総ページ数を表示する形にします。


    ページネーションのレイアウト方針

    管理画面向けのページネーションでは、以下のような構成が使いやすいと思います。

    全120件中 1〜10件を表示        表示件数 10件   <  1  2  3  ...  12  >

    ただし、モバイルでは横幅が足りないため、次のように簡略化します。

    全120件中 1〜10件を表示
    
    < 前へ     1 / 12     次へ >

    デバイスによって表示内容を変えることで、どの画面幅でも無理なく使えるようにします。


    ファイル構成

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

    components/
      ui/
        Pagination.tsx
    
    app/
      page.tsx
    

    Pagination は DataTable と同じく、管理画面内のいろいろな一覧で使い回したいので、components/ui/ に作成します。


    Pagination.tsxを作成する

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

    components/ui/Pagination.tsx

    type PaginationProps = {
      currentPage: number;
      totalPages: number;
      totalItems: number;
      pageSize: number;
      pageSizeOptions?: number[];
      onPageChange: (page: number) => void;
      onPageSizeChange?: (pageSize: number) => void;
    };
    
    type PageItem = number | "...";
    
    function getPageItems(currentPage: number, totalPages: number): PageItem[] {
      if (totalPages <= 7) {
        return Array.from({ length: totalPages }, (_, index) => index + 1);
      }
    
      if (currentPage <= 4) {
        return [1, 2, 3, 4, 5, "...", totalPages];
      }
    
      if (currentPage >= totalPages - 3) {
        return [
          1,
          "...",
          totalPages - 4,
          totalPages - 3,
          totalPages - 2,
          totalPages - 1,
          totalPages,
        ];
      }
    
      return [
        1,
        "...",
        currentPage - 1,
        currentPage,
        currentPage + 1,
        "...",
        totalPages,
      ];
    }
    
    export default function Pagination({
      currentPage,
      totalPages,
      totalItems,
      pageSize,
      pageSizeOptions = [10, 20, 50],
      onPageChange,
      onPageSizeChange,
    }: PaginationProps) {
      const startItem =
        totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
    
      const endItem = Math.min(currentPage * pageSize, totalItems);
    
      const pageItems = getPageItems(currentPage, totalPages);
    
      const canGoPrev = currentPage > 1;
      const canGoNext = currentPage < totalPages;
    
      const handlePrev = () => {
        if (!canGoPrev) return;
        onPageChange(currentPage - 1);
      };
    
      const handleNext = () => {
        if (!canGoNext) return;
        onPageChange(currentPage + 1);
      };
    
      return (
        <div className="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white px-4 py-4 sm:px-5 lg:flex-row lg:items-center lg:justify-between">
          <div className="text-sm text-slate-500">
            全{totalItems}件中 {startItem}〜{endItem}件を表示
          </div>
    
          <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
            {onPageSizeChange && (
              <label className="flex items-center gap-2 text-sm text-slate-500">
                <span>表示件数</span>
    
                <select
                  value={pageSize}
                  onChange={(event) => {
                    onPageSizeChange(Number(event.target.value));
                  }}
                  className="h-9 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500"
                >
                  {pageSizeOptions.map((option) => (
                    <option key={option} value={option}>
                      {option}件
                    </option>
                  ))}
                </select>
              </label>
            )}
    
            <div className="hidden items-center gap-1 sm:flex">
              <button
                type="button"
                onClick={handlePrev}
                disabled={!canGoPrev}
                className="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
              >
                前へ
              </button>
    
              <div className="flex items-center gap-1">
                {pageItems.map((pageItem, index) => {
                  if (pageItem === "...") {
                    return (
                      <span
                        key={`ellipsis-${index}`}
                        className="flex h-9 min-w-9 items-center justify-center text-sm text-slate-400"
                      >
                        ...
                      </span>
                    );
                  }
    
                  const isActive = pageItem === currentPage;
    
                  return (
                    <button
                      key={pageItem}
                      type="button"
                      onClick={() => onPageChange(pageItem)}
                      className={[
                        "flex h-9 min-w-9 items-center justify-center rounded-lg px-3 text-sm transition",
                        isActive
                          ? "bg-primary-500 text-white"
                          : "text-slate-600 hover:bg-slate-50",
                      ].join(" ")}
                      aria-current={isActive ? "page" : undefined}
                    >
                      {pageItem}
                    </button>
                  );
                })}
              </div>
    
              <button
                type="button"
                onClick={handleNext}
                disabled={!canGoNext}
                className="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
              >
                次へ
              </button>
            </div>
    
            <div className="flex items-center justify-between gap-3 sm:hidden">
              <button
                type="button"
                onClick={handlePrev}
                disabled={!canGoPrev}
                className="h-10 rounded-lg border border-slate-200 px-4 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
              >
                前へ
              </button>
    
              <div className="text-sm font-medium text-slate-700">
                {currentPage} / {totalPages}
              </div>
    
              <button
                type="button"
                onClick={handleNext}
                disabled={!canGoNext}
                className="h-10 rounded-lg border border-slate-200 px-4 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
              >
                次へ
              </button>
            </div>
          </div>
        </div>
      );
    }

    これで、PCとモバイルで表示が変わるページネーションコンポーネントができました。


    propsの役割

    Pagination で受け取るpropsは以下です。

    type PaginationProps = {
      currentPage: number;     # 現在のページ番号
      totalPages: number;      # 総ページ数
      totalItems: number;      # 総件数
      pageSize: number;        # 1ページ当たりの表示件数
      pageSizeOptions?: number[];   # 表示件数の選択肢
      onPageChange: (page: number) => void;  # ページを変更したときに呼ぶ関数
      onPageSizeChange?: (pageSize: number) => void;  # 表示件数を変更したときに呼ぶ関数
    };

    onPageSizeChange は任意にしています。

    表示件数を切り替えない一覧では、onPageSizeChange を渡さなければセレクトボックスは表示されません。


    ページ番号の配列を作成する

    ページ番号の表示には、getPageItems という関数を使っています。

    type PageItem = number | "...";
    
    function getPageItems(currentPage: number, totalPages: number): PageItem[] {
      if (totalPages <= 7) {
        return Array.from({ length: totalPages }, (_, index) => index + 1);
      }
    
      if (currentPage <= 4) {
        return [1, 2, 3, 4, 5, "...", totalPages];
      }
    
      if (currentPage >= totalPages - 3) {
        return [
          1,
          "...",
          totalPages - 4,
          totalPages - 3,
          totalPages - 2,
          totalPages - 1,
          totalPages,
        ];
      }
    
      return [
        1,
        "...",
        currentPage - 1,
        currentPage,
        currentPage + 1,
        "...",
        totalPages,
      ];
    }

    ページ数が少ない場合は、すべてのページ番号を表示します。

    if (totalPages <= 7) {
      return Array.from({ length: totalPages }, (_, index) => index + 1);
    }

    ページ数が多い場合は、省略記号を使います。

    たとえば、現在ページが中央付近の場合は、次のようになります。

    1 ... 4 5 6 ... 12

    このようにしておくと、ページ数が多くても表示が長くなりすぎません。


    件数表示を作成する

    管理画面では、現在どの範囲のデータを表示しているか分かると便利です。

    そのため、以下のように表示します。

    全120件中 1〜10件を表示

    この表示に使う値は、次のように計算しています。

    const startItem =
      totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
    
    const endItem = Math.min(currentPage * pageSize, totalItems);

    たとえば、以下のような状態だとします。

    currentPage: 2
    pageSize: 10
    totalItems: 35

    この場合、表示範囲は次のようになります。

    全35件中 11〜20件を表示

    最後のページでは、件数を超えないように Math.min を使っています。

    const endItem = Math.min(currentPage * pageSize, totalItems);

    表示件数セレクトを作成する

    表示件数を切り替えられるように、セレクトボックスも用意します。

    {onPageSizeChange && (
      <label className="flex items-center gap-2 text-sm text-slate-500">
        <span>表示件数</span>
    
        <select
          value={pageSize}
          onChange={(event) => {
            onPageSizeChange(Number(event.target.value));
          }}
          className="h-9 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500"
        >
          {pageSizeOptions.map((option) => (
            <option key={option} value={option}>
              {option}件
            </option>
          ))}
        </select>
      </label>
    )}

    表示件数の候補は、デフォルトで [10, 20, 50] にしています。

    使う側で変更することもできます。

    <Pagination
      pageSizeOptions={[10, 30, 100]}
      ...
    />

    前へ・次へボタンを作成する

    前後のページへ移動するボタンも用意します。

    const canGoPrev = currentPage > 1;
    const canGoNext = currentPage < totalPages;

    1ページ目では「前へ」を無効にします。

    disabled={!canGoPrev}

    最終ページでは「次へ」を無効にします。

    disabled={!canGoNext}

    クリック時の処理は次のようにしています。

    const handlePrev = () => {
      if (!canGoPrev) return;
      onPageChange(currentPage - 1);
    };
    
    const handleNext = () => {
      if (!canGoNext) return;
      onPageChange(currentPage + 1);
    };

    無効な状態では、クリックしてもページが変わらないようにしています。


    モバイル表示を調整する

    PCやタブレットでは、ページ番号を表示します。

    <div className="hidden items-center gap-1 sm:flex">
      ...
    </div>

    hidden sm:flex にしているため、モバイルでは非表示になります。

    モバイルでは、代わりに以下のような簡略表示を出します。

    <div className="flex items-center justify-between gap-3 sm:hidden">
      <button type="button" onClick={handlePrev} disabled={!canGoPrev}>
        前へ
      </button>
    
      <div className="text-sm font-medium text-slate-700">
        {currentPage} / {totalPages}
      </div>
    
      <button type="button" onClick={handleNext} disabled={!canGoNext}>
        次へ
      </button>
    </div>

    モバイルでは、ページ番号をすべて並べると横幅が足りなくなりやすいです。

    現在ページと総ページ数だけを表示します。


    DataTableと組み合わせる

    次に、前回作成した DataTable と組み合わせて使ってみます。

    app/page.tsx

    "use client";
    
    import { useMemo, useState } from "react";
    import DashboardLayout from "@/components/dashboard/DashboardLayout";
    import DataTable, { DataTableColumn } from "@/components/ui/DataTable";
    import Pagination from "@/components/ui/Pagination";
    import StatusBadge from "@/components/ui/StatusBadge";
    
    type User = {
      id: number;
      name: string;
      email: string;
      role: string;
      status: "active" | "pending" | "suspended";
    };
    
    const users: User[] = [
      {
        id: 1,
        name: "山田 太郎",
        email: "yamada@example.com",
        role: "管理者",
        status: "active",
      },
      {
        id: 2,
        name: "佐藤 花子",
        email: "sato@example.com",
        role: "編集者",
        status: "pending",
      },
      {
        id: 3,
        name: "田中 一郎",
        email: "tanaka@example.com",
        role: "閲覧者",
        status: "suspended",
      },
      {
        id: 4,
        name: "鈴木 次郎",
        email: "suzuki@example.com",
        role: "編集者",
        status: "active",
      },
      {
        id: 5,
        name: "高橋 美咲",
        email: "takahashi@example.com",
        role: "閲覧者",
        status: "pending",
      },
      {
        id: 6,
        name: "伊藤 健",
        email: "ito@example.com",
        role: "管理者",
        status: "active",
      },
      {
        id: 7,
        name: "渡辺 亮",
        email: "watanabe@example.com",
        role: "閲覧者",
        status: "suspended",
      },
      {
        id: 8,
        name: "中村 彩",
        email: "nakamura@example.com",
        role: "編集者",
        status: "active",
      },
      {
        id: 9,
        name: "小林 翔",
        email: "kobayashi@example.com",
        role: "閲覧者",
        status: "pending",
      },
      {
        id: 10,
        name: "加藤 優",
        email: "kato@example.com",
        role: "管理者",
        status: "active",
      },
      {
        id: 11,
        name: "吉田 葵",
        email: "yoshida@example.com",
        role: "編集者",
        status: "active",
      },
      {
        id: 12,
        name: "山本 大輔",
        email: "yamamoto@example.com",
        role: "閲覧者",
        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>;
      }
    }
    
    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">{user.role}</span>
        ),
      },
      {
        key: "status",
        header: "ステータス",
        render: (user) => renderStatus(user.status),
      },
    ];
    
    export default function Page() {
      const [page, setPage] = useState(1);
      const [pageSize, setPageSize] = useState(5);
    
      const totalItems = users.length;
      const totalPages = Math.ceil(totalItems / pageSize);
    
      const currentUsers = useMemo(() => {
        const start = (page - 1) * pageSize;
        const end = start + pageSize;
    
        return users.slice(start, end);
      }, [page, pageSize]);
    
      const handlePageSizeChange = (nextPageSize: number) => {
        setPageSize(nextPageSize);
        setPage(1);
      };
    
      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">
                DataTableとPaginationを組み合わせた一覧画面です。
                表示件数やページ番号を切り替えられるようにしています。
              </p>
            </section>
    
            <div className="space-y-4">
              <DataTable
                columns={columns}
                data={currentUsers}
                getRowKey={(user) => user.id}
                emptyMessage="ユーザーが見つかりません。"
              />
    
              <Pagination
                currentPage={page}
                totalPages={totalPages}
                totalItems={totalItems}
                pageSize={pageSize}
                pageSizeOptions={[5, 10, 20]}
                onPageChange={setPage}
                onPageSizeChange={handlePageSizeChange}
              />
            </div>
          </div>
        </DashboardLayout>
      );
    }

    これで、DataTable と Pagination を組み合わせたユーザー一覧画面ができます。


    表示件数を変更したら1ページ目に戻す

    表示件数を変更した場合は、現在ページを1ページ目に戻しています。

    const handlePageSizeChange = (nextPageSize: number) => {
      setPageSize(nextPageSize);
      setPage(1);
    };

    たとえば、5件ずつ表示していて3ページ目を見ていたとします。

    そこで20件表示に変更すると、総ページ数が変わります。

    そのまま3ページ目を表示しようとすると、存在しないページになる場合があります。

    そのため、表示件数を変えたら1ページ目に戻すようにしています。


    実装のポイント

    今回のポイントは以下です。

    • Paginationを汎用コンポーネントとして作成する

    • 現在ページ、総ページ数、総件数をpropsで渡す

    • 表示件数を切り替えられるようにする

    • ページ数が多い場合は省略記号を使う

    • PCではページ番号を表示する

    • モバイルでは「現在ページ / 総ページ数」にする

    • DataTableと組み合わせて使う

    管理画面では、一覧画面が多くなりやすいです。

    そのため、DataTable と Pagination を別々のコンポーネントとして作っておくと、いろいろな画面で組み合わせて使えます。


    まとめ

    今回は、Next.jsで管理画面向けの Pagination コンポーネントを作成しました。

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

    • 件数表示

    • 表示件数の切り替え

    • 前へ・次へボタン

    • ページ番号

    • 省略記号

    • モバイル向けの簡略表示

    • DataTableとの組み合わせ

    前回作成した DataTable と組み合わせることで、管理画面らしい一覧画面に近づきました。

    一覧画面では、データ表示、状態表示、ページネーションがよく使われます。

    今後は、検索や絞り込み、URLクエリ連携、API取得などを追加していくと、さらに実務に近い管理画面になりそうです。

    #NextJS #React #ページネーション #コンポーネント #レスポンシブ #テーブル

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

    あなたへのおすすめ