概要
検索ワードを正規表現で split してテキストを分割し、一致部分に <mark> タグを付けてレンダリングする。大文字・小文字を無視した検索、特殊文字のエスケープ、空クエリ時のフォールバックを実装する。
インストール
# 追加インストールは不要
実装
ハイライトコンポーネント
// components/Highlight.tsx
type Props = {
text: string;
query: string;
className?: string;
};
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export default function Highlight({ text, query, className }: Props) {
// 空クエリや空白のみの場合はそのまま返す
const trimmed = query.trim();
if (!trimmed) {
return <span className={className}>{text}</span>;
}
const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "gi");
const parts = text.split(pattern);
return (
<span className={className}>
{parts.map((part, i) =>
pattern.test(part) ? (
<mark
key={i}
className="rounded bg-yellow-200 px-0.5 text-yellow-900"
>
{part}
</mark>
) : (
<span key={i}>{part}</span>
),
)}
</span>
);
}
複数フィールドに対応したリストコンポーネント
// components/SearchableList.tsx
"use client";
import { useState } from "react";
import Highlight from "./Highlight";
type Item = {
id: number;
name: string;
description: string;
};
const ITEMS: Item[] = [
{ id: 1, name: "Next.js App Router", description: "React のサーバーコンポーネントとファイルベースルーティング" },
{ id: 2, name: "Tailwind CSS", description: "ユーティリティファースト CSS フレームワーク" },
{ id: 3, name: "Prisma ORM", description: "TypeScript ファースト な Node.js 向け ORM" },
{ id: 4, name: "Zod", description: "TypeScript ファースト なスキーマバリデーションライブラリ" },
];
export default function SearchableList() {
const [query, setQuery] = useState("");
const filtered = ITEMS.filter(
(item) =>
item.name.toLowerCase().includes(query.toLowerCase()) ||
item.description.toLowerCase().includes(query.toLowerCase()),
);
return (
<div className="space-y-4">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="キーワードで検索…"
className="w-full rounded-lg border px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-300"
/>
{filtered.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-400">
「{query}」に一致するアイテムが見つかりません
</p>
) : (
<ul className="divide-y rounded-lg border">
{filtered.map((item) => (
<li key={item.id} className="px-4 py-3">
<p className="font-medium text-gray-900">
<Highlight text={item.name} query={query} />
</p>
<p className="mt-0.5 text-sm text-gray-500">
<Highlight text={item.description} query={query} />
</p>
</li>
))}
</ul>
)}
</div>
);
}
ページへの組み込み
// app/search/page.tsx
import SearchableList from "@/components/SearchableList";
export default function SearchPage() {
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="mb-6 text-2xl font-bold">ライブラリ検索</h1>
<SearchableList />
</main>
);
}
ポイント
text.split(new RegExp(($), "gi"))はキャプチャグループ付きの正規表現で分割することで、一致した文字列も配列に含めて返す。これにより元の大文字・小文字を保持したまま<mark>で囲めるescapeRegExpでユーザー入力の特殊文字(.*?など)をエスケープする。これを省略すると正規表現として解釈されて意図しないマッチやランタイムエラーが発生する"gi"フラグのiで大文字・小文字を無視した検索を実現する。gはグローバルマッチで複数箇所を一度に置換するために必要pattern.test(part)は RegExp のlastIndexを更新するため、split後に同じインスタンスを使い回すと挙動がずれることがある。毎回新しいRegExpを生成するか、フラグにgを含めない別インスタンスでテストするとよい(上記サンプルではsplitとtestで同一インスタンスを使っているため、実運用ではnew RegExp(escapeRegExp(trimmed), "i")をtest用に別途作成することを推奨)<mark>は HTML のセマンティックタグで検索結果のハイライトに適切。Tailwind のbg-yellow-200とtext-yellow-900でコントラスト比を確保する