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

【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 #コンポーネント #検索 #フィルター #絞り込み

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

    あなたへのおすすめ