見出し画像

【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 #ページネーション #コンポーネント #レスポンシブ #テーブル

いいなと思ったら応援しよう!

ライト もしよろしければ応援をお願いいたします。 いただいたチップでコーヒーを飲んでがんばります!