概要
検索語とフィルタ条件の組み合わせによって 0 件になった原因をメッセージに反映し、クリア操作への導線を提供する EmptyState を実装する。汎用 EmptyState と異なり、実際の検索語をメッセージに埋め込んで「なぜ 0 件か」をユーザーに伝える。
インストール
# 追加インストールは不要
実装
検索特化 EmptyState コンポーネント
// components/SearchEmptyState.tsx
type Props = {
query?: string;
hasActiveFilters?: boolean;
onClearQuery?: () => void;
onClearAllFilters?: () => void;
suggestions?: string[];
};
export default function SearchEmptyState({
query,
hasActiveFilters = false,
onClearQuery,
onClearAllFilters,
suggestions = [],
}: Props) {
return (
<div className="flex flex-col items-center px-6 py-16 text-center">
{/* アイコン */}
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
/>
</svg>
</div>
{/* メッセージ:検索語があるとき */}
{query && (
<p className="mb-1 text-base font-semibold text-gray-800">
<span className="text-blue-600">「{query}」</span> に一致する結果が見つかりません
</p>
)}
{/* メッセージ:フィルタのみのとき */}
{!query && hasActiveFilters && (
<p className="mb-1 text-base font-semibold text-gray-800">
現在のフィルタ条件に一致する結果がありません
</p>
)}
{/* 補足テキスト */}
<p className="mb-6 max-w-sm text-sm text-gray-500">
{query && hasActiveFilters
? "キーワードを変えるか、フィルタ条件を解除してみてください。"
: query
? "スペルを確認するか、別のキーワードをお試しください。"
: "フィルタ条件を変更または解除してみてください。"}
</p>
{/* アクションボタン */}
<div className="flex flex-wrap justify-center gap-2">
{query && onClearQuery && (
<button
onClick={onClearQuery}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
キーワードをクリア
</button>
)}
{hasActiveFilters && onClearAllFilters && (
<button
onClick={onClearAllFilters}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
すべてのフィルタを解除
</button>
)}
</div>
{/* 検索候補 */}
{suggestions.length > 0 && (
<div className="mt-8">
<p className="mb-2 text-xs font-medium text-gray-500">こちらをお探しですか?</p>
<div className="flex flex-wrap justify-center gap-2">
{suggestions.map((s) => (
<button
key={s}
onClick={() => onClearQuery?.()}
className="rounded-full border px-3 py-1 text-sm text-blue-600 hover:bg-blue-50"
>
{s}
</button>
))}
</div>
</div>
)}
</div>
);
}
一覧コンポーネントへの組み込み
// components/SampleSearchList.tsx
"use client";
import { useState } from "react";
import SearchEmptyState from "./SearchEmptyState";
type Item = { id: number; name: string; category: string };
const ITEMS: Item[] = [
{ id: 1, name: "Next.js App Router", category: "routing" },
{ id: 2, name: "Tailwind CSS", category: "styling" },
{ id: 3, name: "Prisma ORM", category: "crud" },
{ id: 4, name: "Zod", category: "validation" },
{ id: 5, name: "Jest", category: "testing" },
];
const SUGGESTIONS = ["React", "TypeScript", "API", "Form"];
export default function SampleSearchList() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const filtered = ITEMS.filter((item) => {
const matchesQuery = !query || item.name.toLowerCase().includes(query.toLowerCase());
const matchesCategory = !category || item.category === category;
return matchesQuery && matchesCategory;
});
const hasFilters = !!category;
return (
<div className="space-y-4">
{/* 検索フォーム */}
<div className="flex gap-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="キーワードで検索…"
className="flex-1 rounded-lg border px-4 py-2 text-sm"
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm"
>
<option value="">すべて</option>
<option value="routing">routing</option>
<option value="styling">styling</option>
<option value="crud">crud</option>
<option value="testing">testing</option>
</select>
</div>
{/* 結果 or EmptyState */}
{filtered.length === 0 ? (
<SearchEmptyState
query={query || undefined}
hasActiveFilters={hasFilters}
onClearQuery={() => setQuery("")}
onClearAllFilters={() => {
setQuery("");
setCategory("");
}}
suggestions={query ? SUGGESTIONS : []}
/>
) : (
<ul className="divide-y rounded-lg border">
{filtered.map((item) => (
<li key={item.id} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">{item.name}</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{item.category}
</span>
</li>
))}
</ul>
)}
</div>
);
}
ポイント
- 検索語 (
query) とフィルタ条件 (hasActiveFilters) の有無で 3 パターンのメッセージを出し分ける。「なぜ 0 件か」の原因をメッセージに反映させることで、ユーザーが次に何をすべきかを理解しやすくなる - 検索語をメッセージに埋め込む(
「${query}」に一致する…)ことで、ユーザーが入力した内容が確認でき、スペルミスに気づきやすくなる suggestionsにキーワード候補を渡すと、関連語のボタンを表示できる。候補をクリックするとクリア操作にフックして次の検索を促せる- クリアボタンは「キーワードをクリア」と「すべてのフィルタを解除」を分けて提供する。フィルタのみが原因のときはキーワードクリアボタンを非表示にしてノイズを減らす
- 汎用 EmptyState(tailwind-empty-state-ui)との違い: この実装は検索語・フィルタ条件を Props として受け取りメッセージに反映させる点が特徴。汎用版は
title/descriptionを静的に渡す設計