見出し画像

【Next.js】管理画面で使える検索・絞り込みバーを作成

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

その前には、一覧表示用の DataTable、状態表示用の StatusBadge も作成しました。

ここまでで、管理画面の一覧に必要な基本要素として、

  • データを表示する

  • 状態をバッジで表示する

  • ページを切り替える

  • 表示件数を変更する

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

次に必要になるのは、一覧から目的のデータを探すための 検索・絞り込み です。

管理画面では、データ件数が増えてくると、ただ一覧を表示するだけでは使いにくくなります。

たとえば、ユーザー一覧であれば、

  • 名前で検索したい

  • メールアドレスで検索したい

  • ステータスで絞り込みたい

  • 権限で絞り込みたい

  • 検索条件をリセットしたい

といった操作が必要になります。

そこで今回は、管理画面で使いやすい 検索・絞り込みバー を作成します。


今回作成するもの

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

<SearchFilterBar
  onSearch={(values) => {
    console.log(values);
  }}
  onReset={() => {
    console.log("リセット");
  }}
/>

今回の検索・絞り込みバーでは、以下の項目を扱います。

  • キーワード検索

  • ステータス絞り込み

  • 権限絞り込み

  • 検索ボタン

  • リセットボタン

ユーザー一覧を想定して、以下のような検索条件にします。

type SearchFilterValues = {
  keyword: string;
  status: string;
  role: string;
};

keyword には名前やメールアドレスを入力します。
status では「有効」「承認待ち」「停止中」などを絞り込みます。
role では「管理者」「編集者」「閲覧者」などを絞り込みます。


完成形

以下のような検索・絞り込みバーを作成します。

検索フィルターを追加した一覧画面

PCでは横並びに表示し、画面幅が狭い場合は縦に並ぶようにします。


ファイル構成

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

components/
  ui/
    SearchFilterBar.tsx

app/
  page.tsx

SearchFilterBar は一覧画面で使い回すUIなので、components/ui/ に作成します。


SearchFilterBarで実装する機能

今回の SearchFilterBar では、以下を実装します。

  • キーワード入力

  • ステータス選択

  • 権限選択

  • 検索ボタン

  • リセットボタン

  • 入力値のstate管理

  • 検索条件を親コンポーネントに渡す

  • リセット時に初期状態へ戻す

検索・絞り込みバー自体は、データの絞り込み処理までは行いません。

あくまで、

  • 検索条件を入力する

  • 検索ボタンを押す

  • 親コンポーネントに条件を渡す

ところまでを担当します。

実際にデータを絞り込む処理は、親コンポーネント側で行います。


SearchFilterBar.tsxを作成する

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

components/ui/SearchFilterBar.tsx

"use client";

import { FormEvent, useState } from "react";

export type SearchFilterValues = {
  keyword: string;
  status: string;
  role: string;
};

type SearchFilterBarProps = {
  defaultValues?: Partial<SearchFilterValues>;
  onSearch: (values: SearchFilterValues) => void;
  onReset?: () => void;
};

const initialValues: SearchFilterValues = {
  keyword: "",
  status: "all",
  role: "all",
};

export default function SearchFilterBar({
  defaultValues,
  onSearch,
  onReset,
}: SearchFilterBarProps) {
  const [values, setValues] = useState<SearchFilterValues>({
    ...initialValues,
    ...defaultValues,
  });

  const handleChange = (name: keyof SearchFilterValues, value: string) => {
    setValues((currentValues) => ({
      ...currentValues,
      [name]: value,
    }));
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    onSearch(values);
  };

  const handleReset = () => {
    const resetValues = {
      ...initialValues,
      ...defaultValues,
    };

    setValues(resetValues);
    onSearch(resetValues);
    onReset?.();
  };

  return (
    <form
      onSubmit={handleSubmit}
      className="rounded-lg border border-slate-200 bg-white p-4"
    >
      <div className="grid gap-4 lg:grid-cols-[1fr_180px_180px_auto] lg:items-end">
        <div>
          <label
            htmlFor="keyword"
            className="mb-2 block text-sm font-medium text-slate-700"
          >
            キーワード
          </label>

          <input
            id="keyword"
            type="search"
            value={values.keyword}
            onChange={(event) => handleChange("keyword", event.target.value)}
            placeholder="名前・メールアドレスで検索"
            className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-primary-500 focus:bg-white"
          />
        </div>

        <div>
          <label
            htmlFor="status"
            className="mb-2 block text-sm font-medium text-slate-700"
          >
            ステータス
          </label>

          <select
            id="status"
            value={values.status}
            onChange={(event) => handleChange("status", event.target.value)}
            className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500 focus:bg-white"
          >
            <option value="all">すべて</option>
            <option value="active">有効</option>
            <option value="pending">承認待ち</option>
            <option value="suspended">停止中</option>
          </select>
        </div>

        <div>
          <label
            htmlFor="role"
            className="mb-2 block text-sm font-medium text-slate-700"
          >
            権限
          </label>

          <select
            id="role"
            value={values.role}
            onChange={(event) => handleChange("role", event.target.value)}
            className="h-11 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500 focus:bg-white"
          >
            <option value="all">すべて</option>
            <option value="admin">管理者</option>
            <option value="editor">編集者</option>
            <option value="viewer">閲覧者</option>
          </select>
        </div>

        <div className="flex gap-2">
          <button
            type="submit"
            className="h-11 rounded-lg bg-primary-500 px-4 text-sm font-medium text-white transition hover:bg-primary-700"
          >
            検索
          </button>

          <button
            type="button"
            onClick={handleReset}
            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"
          >
            リセット
          </button>
        </div>
      </div>
    </form>
  );
}

これで、検索・絞り込みバーの基本形ができました。


propsの役割

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

type SearchFilterBarProps = {
  defaultValues?: Partial<SearchFilterValues>;
  onSearch: (values: SearchFilterValues) => void;
  onReset?: () => void;
};

それぞれの役割は次の通りです。

  • defaultValues  
    初期値を指定する

  • onSearch  
    検索ボタンを押したときに呼ばれる

  • onReset  
    リセット時に追加で実行したい処理

defaultValues は任意です。

defaultValues?: Partial<SearchFilterValues>;

指定しない場合は、以下の初期値が使われます。

const initialValues: SearchFilterValues = {
  keyword: "",
  status: "all",
  role: "all",
};

status や role は、すべてを表す値として "all" を使っています。


入力値を管理する

入力値は、SearchFilterBar 内部の useState で管理しています。

const [values, setValues] = useState<SearchFilterValues>({
  ...initialValues,
  ...defaultValues,
});

入力値を更新するときは、handleChange を使います。

const handleChange = (name: keyof SearchFilterValues, value: string) => {
  setValues((currentValues) => ({
    ...currentValues,
    [name]: value,
  }));
};

たとえば、キーワード入力では次のように使っています。

<input
  value={values.keyword}
  onChange={(event) => handleChange("keyword", event.target.value)}
/>

ステータスや権限のセレクトボックスも同じ考え方です。

<select
  value={values.status}
  onChange={(event) => handleChange("status", event.target.value)}
>

検索ボタンを押したときの処理

検索ボタンを押すと、フォームの onSubmit が実行されます。

<form onSubmit={handleSubmit}>

handleSubmit では、ページの再読み込みを防いでから、親コンポーネントへ検索条件を渡します。

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
  event.preventDefault();
  onSearch(values);
};

これにより、親コンポーネント側で検索処理を実行できます。

<SearchFilterBar
  onSearch={(values) => {
    console.log(values);
  }}
/>

リセットボタンを押したときの処理

リセットボタンを押した場合は、検索条件を初期状態に戻します。

const handleReset = () => {
  const resetValues = {
    ...initialValues,
    ...defaultValues,
  };

  setValues(resetValues);
  onSearch(resetValues);
  onReset?.();
};

ここでは、内部のstateを戻すだけでなく、onSearch(resetValues) も呼んでいます。

onSearch(resetValues);

これにより、親コンポーネント側の検索条件もリセットされます。

追加で何か処理したい場合は、onReset を渡せます。

onReset?.();

レスポンシブ対応

検索・絞り込みバーは、PCでは横並びにしたいですが、モバイルでは縦に並べた方が見やすいです。

今回のレイアウトでは、以下のように指定しています。

<div className="grid gap-4 lg:grid-cols-[1fr_180px_180px_auto] lg:items-end">

通常は縦に並びます。

モバイルでは縦並び

lg 以上では横並びになります。

タブレット以上だと横並び

キーワード入力は広く取り、セレクトボックスは固定幅にしています。

管理画面では、検索欄は広く、絞り込み項目は少し狭めにするとバランスが取りやすいです。


DataTableとPaginationに組み合わせる

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

今回は、ユーザー一覧を例にします。

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 SearchFilterBar, {
  SearchFilterValues,
} from "@/components/ui/SearchFilterBar";
import StatusBadge from "@/components/ui/StatusBadge";

type User = {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  status: "active" | "pending" | "suspended";
};

const users: 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",
  },
  {
    id: 4,
    name: "鈴木 次郎",
    email: "suzuki@example.com",
    role: "editor",
    status: "active",
  },
  {
    id: 5,
    name: "高橋 美咲",
    email: "takahashi@example.com",
    role: "viewer",
    status: "pending",
  },
  {
    id: 6,
    name: "伊藤 健",
    email: "ito@example.com",
    role: "admin",
    status: "active",
  },
  {
    id: 7,
    name: "渡辺 亮",
    email: "watanabe@example.com",
    role: "viewer",
    status: "suspended",
  },
  {
    id: 8,
    name: "中村 彩",
    email: "nakamura@example.com",
    role: "editor",
    status: "active",
  },
  {
    id: 9,
    name: "小林 翔",
    email: "kobayashi@example.com",
    role: "viewer",
    status: "pending",
  },
  {
    id: 10,
    name: "加藤 優",
    email: "kato@example.com",
    role: "admin",
    status: "active",
  },
  {
    id: 11,
    name: "吉田 葵",
    email: "yoshida@example.com",
    role: "editor",
    status: "active",
  },
  {
    id: 12,
    name: "山本 大輔",
    email: "yamamoto@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 "閲覧者";
  }
}

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),
  },
];

const initialFilters: SearchFilterValues = {
  keyword: "",
  status: "all",
  role: "all",
};

export default function Page() {
  const [filters, setFilters] =
    useState<SearchFilterValues>(initialFilters);
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(5);

  const filteredUsers = useMemo(() => {
    return users.filter((user) => {
      const keyword = filters.keyword.trim().toLowerCase();

      const matchesKeyword =
        keyword.length === 0 ||
        user.name.toLowerCase().includes(keyword) ||
        user.email.toLowerCase().includes(keyword);

      const matchesStatus =
        filters.status === "all" || user.status === filters.status;

      const matchesRole =
        filters.role === "all" || user.role === filters.role;

      return matchesKeyword && matchesStatus && matchesRole;
    });
  }, [filters]);

  const totalItems = filteredUsers.length;
  const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));

  const currentUsers = useMemo(() => {
    const start = (page - 1) * pageSize;
    const end = start + pageSize;

    return filteredUsers.slice(start, end);
  }, [filteredUsers, page, pageSize]);

  const handleSearch = (values: SearchFilterValues) => {
    setFilters(values);
    setPage(1);
  };

  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>

        <SearchFilterBar
          defaultValues={initialFilters}
          onSearch={handleSearch}
        />

        <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>
  );
}

これで、検索・絞り込み、テーブル表示、ページネーションを組み合わせた一覧画面ができます。


絞り込み処理の考え方

絞り込み処理は、親コンポーネント側で行っています。

const filteredUsers = useMemo(() => {
  return users.filter((user) => {
    const keyword = filters.keyword.trim().toLowerCase();

    const matchesKeyword =
      keyword.length === 0 ||
      user.name.toLowerCase().includes(keyword) ||
      user.email.toLowerCase().includes(keyword);

    const matchesStatus =
      filters.status === "all" || user.status === filters.status;

    const matchesRole =
      filters.role === "all" || user.role === filters.role;

    return matchesKeyword && matchesStatus && matchesRole;
  });
}, [filters]);

キーワードは、名前とメールアドレスに対して検索しています。

user.name.toLowerCase().includes(keyword) ||
user.email.toLowerCase().includes(keyword)

ステータスと権限は、all の場合はすべて表示します。

filters.status === "all" || user.status === filters.status

filters.role === "all" || user.role === filters.role

このようにしておくと、検索条件を追加したい場合も拡張しやすくなります。


検索したら1ページ目に戻す

検索条件を変更した場合は、ページを1ページ目に戻しています。

const handleSearch = (values: SearchFilterValues) => {
  setFilters(values);
  setPage(1);
};

これは、絞り込みによって総ページ数が変わるためです。

たとえば、検索前は3ページ目を見ていたとしても、検索後の結果が1ページ分しかない場合があります。

そのため、検索したタイミングで setPage(1) を実行しています。

表示件数を変更した場合も同じです。

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

一覧画面では、検索や表示件数の変更時にページ番号をリセットするのが自然です。


実装のポイント

今回のポイントは、検索・絞り込みバーを単体のコンポーネントとして作ったことです。

  • SearchFilterBar  
    検索条件の入力を担当

  • 親コンポーネント  
    検索条件を受け取ってデータを絞り込む

  • DataTable  
    絞り込み後のデータを表示する

  • Pagination  
    絞り込み後の件数に対してページを切り替える

SearchFilterBar は、データの中身を知りません。

検索条件を入力し、onSearch で親に渡すだけです。

実際にどう絞り込むかは、親コンポーネント側で決めます。

このように役割を分けることで、SearchFilterBar を他の一覧画面でも使い回しやすくなります。


まとめ

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

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

  • キーワード検索

  • ステータス絞り込み

  • 権限絞り込み

  • 検索ボタン

  • リセットボタン

  • レスポンシブ対応

  • DataTableとの組み合わせ

  • Paginationとの組み合わせ

回の SearchFilterBar を追加することで、一覧画面がかなり実用的になりました。

次は、検索条件やページ番号をURLクエリと連動させると、さらに管理画面らしい動きにできそうです。

#NextJS #React #コンポーネント #検索 #フィルター #絞り込み

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

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