【現場完全ガイド】Laravelの検索・一覧・フィルタUX――Eloquent/Scout/Meilisearch、ページネーション、ソート、ファセット、アクセシブルなテーブル&カード表示
この記事で学べること(要点)
- 検索・一覧・フィルタの情報設計と、Eloquent/クエリビルダ/Scout の使い分け
- ソート/並び替えのホワイトリスト化、ページネーション、クエリ文字列の永続化
- Meilisearch/Elasticsearch 連携(Laravel Scout)と、全文検索×RDB の役割分担
- アクセシブルな結果一覧:テーブル/カードの構造、
aria-sort、aria-live、aria-busy、キーボード到達性 - ファセット(絞り込み)UI、検索ボックス(コンボボックス/APG準拠)、「該当なし」時の導線
- エクスポート、ブックマーク共有、SEO/パフォーマンス、テスト観点まで
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:安定した一覧・検索機能を安全にスケールさせたい方
- SaaS/EC/メディアのテックリード:サーバ負荷/検索精度/UX/アクセシビリティを横断的に整えたい方
- デザイナー/ライター/QA:テーブルやカードの読み上げ、フィルタ/ソートの操作手順を標準化したい方
アクセシビリティレベル:★★★★★
role="search"・コンボボックス(APG準拠)・aria-sort・aria-live・aria-busy・色に依存しないバッジ・キーボードで完遂できるフィルタ操作・「該当なし」時の代替導線・prefers-reduced-motion配慮まで網羅。
1. まずは設計:検索・一覧・フィルタの責務分担
- 検索(Search):自由語や曖昧語で候補を広く集める。全文検索エンジン(Scout)を使うと関連度が出しやすい。
- フィルタ(Filter):確定条件(価格帯、カテゴリ、在庫、有効/無効 など)を左側面で絞る。RDB の範疇。
- ソート(Sort):明確な列基準で並べ替える。「人気順」「価格」「新着」など。ホワイトリストで安全化。
- ページネーション(Paginate):安定した順序と軽量な移動を担保。
- 表示(View):テーブルorカード。意味構造を尊重し、色以外の手掛かり(テキスト/アイコン)を併用。
この分担を決めると、実装がぶれず、性能/UX/アクセシビリティの両立がしやすくなります。
2. ディレクトリ構成とルーティング(検索結果を「共有」しやすく)
app/
├─ Http/
│ ├─ Controllers/SearchController.php
│ └─ Requests/SearchRequest.php
├─ Models/Product.php
└─ Services/
├─ ProductSearchService.php // Scout/全文検索
└─ ProductFilterQuery.php // RDBフィルタ
resources/views/search/
├─ index.blade.php
├─ _filters.blade.php
├─ _table.blade.php
└─ _cards.blade.php
// routes/web.php
Route::get('/products', [SearchController::class, 'index'])->name('products.index'); // 一覧(検索/フィルタ/ソート/ページング)
Route::get('/products/export', [SearchController::class, 'export'])->name('products.export'); // CSVなど
クエリ文字列(例:/products?q=laravel&category=books&sort=-created_at&page=2)で状態を表現すると、ブックマーク・共有・SEO にも利点があります。
3. 入力検証とホワイトリスト(安全と再現性の土台)
// app/Http/Requests/SearchRequest.php
class SearchRequest extends FormRequest
{
public function rules(): array
{
return [
'q' => ['nullable','string','max:100'],
'category' => ['nullable','string','max:50'],
'min_price' => ['nullable','integer','min:0','max:10000000'],
'max_price' => ['nullable','integer','min:0','max:10000000','gte:min_price'],
'in_stock' => ['nullable','boolean'],
'sort' => ['nullable','in:created_at,-created_at,price,-price,name,-name,popularity,-popularity'],
'view' => ['nullable','in:table,card'],
'page' => ['nullable','integer','min:1'],
'per_page' => ['nullable','integer','min:10','max:100'],
];
}
public function validatedForQuery(): array
{
$v = $this->validated();
$v['sort'] = $v['sort'] ?? '-created_at';
$v['per_page'] = $v['per_page'] ?? 20;
return $v;
}
}
- ソートは列挙で許可(
in:)。任意列は危険。 - 価格など数値は範囲を限定。
- 1ページ件数も下限/上限で帯域を守る。
4. コントローラ:検索→フィルタ→ソート→ページングの順に
// app/Http/Controllers/SearchController.php
class SearchController extends Controller
{
public function __construct(
private ProductSearchService $searcher,
private ProductFilterQuery $filter
) {}
public function index(SearchRequest $req)
{
$p = $req->validatedForQuery();
// 1) 検索語あり: Scout/全文検索で候補のID集合を取得
$ids = null;
if (!empty($p['q'])) {
$ids = $this->searcher->ids($p['q']); // 上位N件のID配列
if ($ids === []) {
// ヒット0件時は空結果+提案
return view('search.index', [
'items' => collect(),
'p' => $p,
'facets' => $this->filter->facets(), // 全体傾向の表示も可
'total' => 0,
]);
}
}
// 2) RDB側でフィルタ/ソート/ページング(IDsがあれば in で限定)
[$items, $total] = $this->filter->run($p, $ids);
return view('search.index', [
'items' => $items,
'p' => $p,
'facets' => $this->filter->facets($p, $ids),
'total' => $total,
]);
}
public function export(SearchRequest $req)
{
$p = $req->validatedForQuery();
[$query] = $this->filter->queryOnly($p, null); // エクスポート用のビルダ
$headers = ['Content-Type' => 'text/csv; charset=UTF-8'];
return response()->streamDownload(function() use($query){
$out = fopen('php://output','w');
fputcsv($out, ['ID','名前','価格','カテゴリ','在庫','作成日']);
$query->chunkById(1000, function($rows) use($out){
foreach ($rows as $r) {
fputcsv($out, [$r->id,$r->name,$r->price,$r->category,$r->stock,$r->created_at]);
}
});
fclose($out);
}, 'products.csv', $headers);
}
}
5. RDB フィルタ層とファセットの算出
// app/Services/ProductFilterQuery.php
class ProductFilterQuery
{
public function run(array $p, ?array $ids): array
{
[$q] = $this->queryOnly($p, $ids);
$items = $q->paginate($p['per_page'])->appends($p); // 状態をクエリに保持
return [$items, $items->total()];
}
public function queryOnly(array $p, ?array $ids): array
{
$q = Product::query()->select(['id','name','price','category','stock','created_at']);
if ($ids !== null) {
$q->whereIn('id', $ids);
// Scoutの関連度順を保つなら FIELD(id, ...) などDB依存の並び替えへ
}
if (!empty($p['category'])) {
$q->where('category', $p['category']);
}
if (isset($p['in_stock'])) {
$p['in_stock'] ? $q->where('stock','>',0) : $q->where('stock',0);
}
if (!empty($p['min_price'])) $q->where('price','>=',$p['min_price']);
if (!empty($p['max_price'])) $q->where('price','<=',$p['max_price']);
// ソート(列挙済み)
$dir = str_starts_with($p['sort'],'-') ? 'desc' : 'asc';
$col = ltrim($p['sort'],'-');
$q->orderBy($col, $dir)->orderBy('id','asc'); // 安定ソート
return [$q];
}
public function facets(array $p = [], ?array $ids = null): array
{
// 代表:カテゴリ別件数(簡易版)
$q = Product::query();
if ($ids) $q->whereIn('id',$ids);
if (!empty($p['min_price'])) $q->where('price','>=',$p['min_price']);
if (!empty($p['max_price'])) $q->where('price','<=',$p['max_price']);
if (isset($p['in_stock'])) $p['in_stock'] ? $q->where('stock','>',0) : $q->where('stock',0);
return $q->selectRaw('category, COUNT(*) as cnt')
->groupBy('category')->orderByDesc('cnt')->limit(20)->get()
->map(fn($r)=>['value'=>$r->category,'count'=>$r->cnt])->all();
}
}
ポイント
- ファセットは軽量な集計を優先(必要に応じて素材テーブル化)。
- ソートは安定化(第二キーに
id)でページングの再現性を保つ。 - Scout 連携時は「IDs による候補限定」→「RDB で厳格フィルタ」という二段構成が扱いやすいです。
6. Scout×Meilisearch/Elasticsearch:いつ使う?
- 自由語/誤字許容/類義語/関連度順が必要な場合に全文検索が効きます。
- RDB の
LIKE '%foo%'は負荷と精度の面で限界が早いです。 - 書き込みは RDB、読み取り(検索)は検索エンジンに寄せる、が基本の分担。
// app/Models/Product.php
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'category' => $this->category,
'tags' => $this->tags->pluck('name'),
];
}
}
// app/Services/ProductSearchService.php
class ProductSearchService
{
public function ids(string $q): array
{
/** @var \Laravel\Scout\Builder $builder */
$builder = Product::search($q)->take(2000); // 上限
// Meilisearchなら $builder->where('stock > 0') なども可能
return $builder->keys()->all(); // IDの配列だけ取得
}
}
運用のコツ
- インデックス更新はキューに載せる。
- サジェストは別インデックス(人気語/タグ)で軽量に。
- 同義語/除外語は運用ルール化。
7. ビューの骨格:検索フォームとフィルタは「だれでも操作できる」形に
{{-- resources/views/search/index.blade.php --}}
@extends('layouts.app')
@section('title','商品一覧')
@section('content')
<div class="container mx-auto">
{{-- 結果件数のライブ告知 --}}
<div id="search-status" role="status" aria-live="polite" class="sr-only">
{{ $total }}件の結果が見つかりました
</div>
<form role="search" aria-label="商品検索" method="get" action="{{ route('products.index') }}" class="mb-4">
<label for="q" class="block font-medium">キーワード</label>
<input id="q" name="q" value="{{ $p['q'] ?? '' }}" class="border rounded px-3 py-2 w-full"
placeholder="商品名やタグで検索" autocomplete="off">
<p class="text-sm text-gray-600 mt-1">例:Laravel 本 / テンプレート</p>
</form>
<div class="grid grid-cols-12 gap-6">
{{-- サイドフィルタ --}}
<aside class="col-span-12 md:col-span-3" aria-labelledby="filter-title">
<h2 id="filter-title" class="text-lg font-semibold mb-2">絞り込み</h2>
@include('search._filters', ['p'=>$p,'facets'=>$facets])
</aside>
{{-- 結果 --}}
<section class="col-span-12 md:col-span-9" aria-labelledby="result-title" aria-busy="false" id="result">
<div class="flex items-center justify-between mb-2">
<h2 id="result-title" class="text-lg font-semibold">{{ number_format($total) }}件</h2>
<form method="get" action="{{ route('products.index') }}" class="flex items-center gap-2">
@foreach($p as $k=>$v) @if(!in_array($k,['sort','view','page']))
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endif @endforeach
<label for="sort" class="sr-only">並び替え</label>
<select id="sort" name="sort" class="border rounded px-2 py-1">
@foreach(['-created_at'=>'新着','created_at'=>'古い順','-price'=>'価格が高い','price'=>'価格が安い','name'=>'名前A→Z','-popularity'=>'人気'] as $value=>$label)
<option value="{{ $value }}" @selected(($p['sort'] ?? '-created_at')===$value)>{{ $label }}</option>
@endforeach
</select>
<label for="view" class="sr-only">表示形式</label>
<select id="view" name="view" class="border rounded px-2 py-1">
<option value="table" @selected(($p['view'] ?? 'table')==='table')>表</option>
<option value="card" @selected(($p['view'] ?? 'table')==='card')>カード</option>
</select>
<button class="px-3 py-1 border rounded">適用</button>
</form>
</div>
@if(($p['view'] ?? 'table') === 'table')
@include('search._table', ['items'=>$items, 'p'=>$p])
@else
@include('search._cards', ['items'=>$items, 'p'=>$p])
@endif
<div class="mt-4">
{{ $items->links() }}
</div>
<p class="mt-2 text-sm">
<a class="underline" href="{{ route('products.export', $p) }}">CSVをダウンロード</a>
</p>
</section>
</div>
</div>
@endsection
8. フィルタ UI(フォームで完結、色に依存しない)
{{-- resources/views/search/_filters.blade.php --}}
<form method="get" action="{{ route('products.index') }}" aria-describedby="filter-help">
{{-- 既存パラメータを維持 --}}
<input type="hidden" name="q" value="{{ $p['q'] ?? '' }}">
<input type="hidden" name="sort" value="{{ $p['sort'] ?? '-created_at' }}">
<input type="hidden" name="view" value="{{ $p['view'] ?? 'table' }}">
<fieldset class="mb-4">
<legend class="font-medium">カテゴリ</legend>
<ul class="mt-2 space-y-1">
@foreach($facets as $f)
@php $checked = ($p['category'] ?? null) === $f['value']; @endphp
<li>
<label class="inline-flex items-center">
<input type="radio" name="category" value="{{ $f['value'] }}" @checked($checked)>
<span class="ml-2">{{ $f['value'] }}</span>
<span class="ml-1 text-gray-500">({{ $f['count'] }})</span>
</label>
</li>
@endforeach
<li>
<label class="inline-flex items-center">
<input type="radio" name="category" value="" @checked(empty($p['category']))>
<span class="ml-2">指定なし</span>
</label>
</li>
</ul>
</fieldset>
<fieldset class="mb-4">
<legend class="font-medium">価格帯</legend>
<div class="flex gap-2 items-center">
<label for="min_price" class="sr-only">最低価格</label>
<input id="min_price" name="min_price" inputmode="numeric" class="border rounded px-2 py-1 w-24"
value="{{ $p['min_price'] ?? '' }}" placeholder="最小">
<span aria-hidden="true">〜</span>
<label for="max_price" class="sr-only">最高価格</label>
<input id="max_price" name="max_price" inputmode="numeric" class="border rounded px-2 py-1 w-24"
value="{{ $p['max_price'] ?? '' }}" placeholder="最大">
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="font-medium">在庫</legend>
<label class="inline-flex items-center">
<input type="checkbox" name="in_stock" value="1" @checked(($p['in_stock'] ?? null) == 1)>
<span class="ml-2">在庫ありのみ</span>
</label>
</fieldset>
<button class="px-3 py-1 border rounded">絞り込み</button>
<p id="filter-help" class="text-sm text-gray-600 mt-2">キーボード操作に対応。Enterで適用されます。</p>
</form>
fieldset/legendでグループを明確に。- 件数は色でなくテキストで提示。
- 表示切替やソートはフォーム送信でも達成可能(JSに依存しない)。
9. テーブル表示:見出しと aria-sort
{{-- resources/views/search/_table.blade.php --}}
<table class="w-full border-collapse">
<caption class="sr-only">検索結果</caption>
<thead>
<tr>
@php $sort = $p['sort'] ?? '-created_at'; @endphp
@php
$cols = [
'name' => '商品名',
'price' => '価格',
'category' => 'カテゴリ',
'stock' => '在庫',
'created_at' => '作成日',
];
@endphp
@foreach($cols as $key => $label)
@php
$dir = 'none';
if (ltrim($sort,'-') === $key) $dir = str_starts_with($sort,'-') ? 'descending' : 'ascending';
$next = $dir === 'ascending' ? "-{$key}" : $key;
if ($dir==='none') $next = $key; // 初回は昇順→必要に応じて設計
$url = route('products.index', array_merge($p, ['sort'=>$next, 'page'=>1]));
@endphp
<th scope="col" class="text-left border-b py-2">
<a href="{{ $url }}" aria-sort="{{ $dir }}" class="underline">
{{ $label }}
<span class="sr-only">
@if($dir==='ascending')(昇順、クリックで降順)@elseif($dir==='descending')(降順、クリックで昇順)@else(並び替え)@endif
</span>
</a>
</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse($items as $it)
<tr class="border-b">
<td class="py-2">{{ $it->name }}</td>
<td class="py-2">¥{{ number_format($it->price) }}</td>
<td class="py-2">{{ $it->category }}</td>
<td class="py-2">
@if($it->stock > 0)
<span>在庫あり</span>
@else
<span>在庫なし</span>
@endif
</td>
<td class="py-2">{{ $it->created_at->format('Y-m-d') }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-6 text-center">
該当する結果がありません。条件を広げるか、人気のカテゴリから探してみませんか?
<a class="underline" href="{{ route('products.index', ['sort'=>'-popularity']) }}">人気順で表示</a>
</td>
</tr>
@endforelse
</tbody>
</table>
- 列ヘッダに並び替えリンクと
aria-sortを付与。 - 視覚アイコンに頼らず、スクリーンリーダー向け文言で状態を説明。
- 「該当なし」時は次の行動を明示。
10. カード表示:意味づけとバッジの読み上げ
{{-- resources/views/search/_cards.blade.php --}}
<ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" role="list">
@forelse($items as $it)
<li class="border rounded p-3" aria-label="{{ $it->name }}">
<h3 class="font-semibold text-lg">
<a class="underline" href="{{ route('products.index', ['q'=>$it->name]) }}">{{ $it->name }}</a>
</h3>
<p class="mt-1">価格:¥{{ number_format($it->price) }}</p>
<p class="mt-1">カテゴリ:{{ $it->category }}</p>
<p class="mt-1">
@if($it->stock > 0)
<span class="inline-block px-2 py-0.5 rounded bg-green-100 text-green-800">在庫あり</span>
@else
<span class="inline-block px-2 py-0.5 rounded bg-gray-200 text-gray-800">在庫なし</span>
@endif
</p>
<p class="mt-1 text-sm text-gray-600">登録:{{ $it->created_at->format('Y-m-d') }}</p>
</li>
@empty
<li>該当する結果がありません。</li>
@endforelse
</ul>
- バッジは色だけでなく文字を含める。
role="list"で読み上げのまとまりを補助。
11. コンボボックス(検索サジェスト)のミニ実装指針
要点
- APG(Authoring Practices)準拠で
role="combobox"aria-autocomplete="list"を用いる。 - 入力にフォーカス→
listboxを表示→上下キーで移動→Enterで確定。 - マウス/タッチなしでも完遂可能に。
(簡易例・概念)
<div class="relative" id="searchbox"
role="combobox" aria-expanded="false" aria-owns="suggestions" aria-haspopup="listbox">
<input id="q" aria-controls="suggestions" aria-autocomplete="list" aria-activedescendant="">
<ul id="suggestions" role="listbox" class="absolute bg-white border w-full hidden"></ul>
</div>
<script type="module">
const box = document.getElementById('searchbox');
const input = document.getElementById('q');
const list = document.getElementById('suggestions');
let active = -1;
let data = [];
input.addEventListener('input', async () => {
const q = input.value.trim();
if (!q) { list.innerHTML=''; list.classList.add('hidden'); box.setAttribute('aria-expanded','false'); return; }
// ここでAPI(/api/suggest?q=...)から候補取得(上限10)
data = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`).then(r => r.json());
list.innerHTML = data.map((it,i)=> `<li role="option" id="opt-${i}" class="px-3 py-1">${it}</li>`).join('');
list.classList.remove('hidden');
box.setAttribute('aria-expanded','true');
active = -1;
input.setAttribute('aria-activedescendant','');
});
input.addEventListener('keydown', e => {
if (!['ArrowDown','ArrowUp','Enter','Escape'].includes(e.key)) return;
const options = [...list.querySelectorAll('[role="option"]')];
if (e.key==='ArrowDown'){ active = Math.min(active+1, options.length-1); e.preventDefault(); }
if (e.key==='ArrowUp'){ active = Math.max(active-1, 0); e.preventDefault(); }
options.forEach((opt,i)=> opt.classList.toggle('bg-blue-100', i===active));
if (active>=0) input.setAttribute('aria-activedescendant', options[active].id);
if (e.key==='Enter' && active>=0){ input.value = options[active].textContent; list.classList.add('hidden'); box.setAttribute('aria-expanded','false'); }
if (e.key==='Escape'){ list.classList.add('hidden'); box.setAttribute('aria-expanded','false'); }
});
</script>
注意:サジェストは補助であり、フォーム送信で必ず検索できるようにします。
12. ページネーションとブックマーク共有
->appends($p)で条件を保持。- 無限スクロールはアクセシビリティの難度が高いので、「さらに読み込む」ボタンを併置し、
role="status"で進捗を告知。 - 検索結果ページは URL で状態を表現し、ブックマーク/共有に強くします。
13. 「該当なし」時の優しい導線
- 条件のサマリ(例:カテゴリ=本、価格≤1,000 円、在庫あり)をテキストで表示。
- 条件を1クリックで解除できるチップ(×)。
- 代替案:人気順リンク、カテゴリ上位、価格帯の拡大、スペル修正の提案。
- 問い合わせ導線(「見つからない?ご連絡ください」)も適宜。
14. パフォーマンス:インデックス・キャッシュ・N+1
- WHERE/ORDER に使う列へ複合インデックス(例:
(category, price, id))。 - ページングは
simplePaginate()でCOUNTコストを削れる場面も。 with()で関連を先読みし、列は最小限に。- 一覧の短命キャッシュ(60秒)やETagで帯域を節約。
- 検索エンジンへのクエリはレート制限/タイムアウトを設定。
15. CSV/Excel エクスポートのアクセシビリティ
- 画面から現在の条件でエクスポート可能に。
- ダウンロードボタンには説明文(
aria-describedby)で「件数が多い場合は時間がかかる」旨を明示。 - 進捗が必要ならジョブ+プログレス(
role="progressbar")で通知。
16. SEO とメタデータ(一覧ページの基本)
rel="canonical"を適切に(特にソートやページ違い)。- タイトルは「キーワード+区切り+サイト名」。
noindexが必要な組み合わせはルール化(極端にスパム的なクエリなど)。- パンくずはセマンティクスを守り、スクリーンリーダーにはシンプルに。
17. テスト:Feature・ブラウザ・アクセシビリティ
17.1 Feature(HTTP/DB)
public function test_filter_and_sort_and_pagination()
{
Product::factory()->count(30)->create(['category'=>'Books']);
Product::factory()->count(10)->create(['category'=>'Goods']);
$res = $this->get('/products?category=Books&sort=-price&per_page=10');
$res->assertOk()->assertSee('件'); // 件数表示
$res->assertSee('表'); // 表示形式セレクトの存在など
}
17.2 ブラウザ(Dusk)
- テーブルヘッダのリンクで並び替わる。
- ページングで条件が維持される。
- 検索語を変えると
aria-live領域が更新される。 - カード/テーブルの切替がキーボードだけで完遂。
17.3 a11y スモーク(axe/Pa11y)
role="search"とlabelの関連付け。aria-sortの値が適切。- 色に依存する情報がない(バッジにテキストあり)。
fieldset/legendでフィルタが意味づく。
18. よくある落とし穴と回避策
- 任意ソートの直渡し → 列挙で固定。
LIKE '%q%'だらけ → Scout/検索エンジンへ。- 無限スクロールだけ → ボタン併置、
role="status"で進捗告知。 - バッジが色だけ → テキストも入れる。
- 「該当なし」で無言 → 条件サマリと提案を必ず。
- ページ遷移でフィルタ消失 →
->appends()と hidden で状態維持。 - 並び替えで順序ブレ → 第二キー(
id)で安定化。 - サジェストがマウス専用 → コンボボックスAPG準拠に。
19. チェックリスト(配布用)
設計
- [ ] 検索(自由語)は全文検索、フィルタはRDB
- [ ] ソートは列挙で安全化、第二キーで安定化
- [ ] クエリ文字列で状態を表現(共有/再現性)
アクセシビリティ
- [ ]
role="search"とラベル/説明 - [ ]
fieldset/legendによるグルーピング - [ ]
aria-sortで並び状態を明示 - [ ]
aria-live/aria-busyで件数/更新を告知 - [ ] 色に依存しないバッジとフィードバック
- [ ] コンボボックスはAPG準拠、キーボード完遂
パフォーマンス
- [ ] 適切なインデックスと先読み
- [ ] 短命キャッシュ/ETag
- [ ] Scoutはキュー/上限/タイムアウト
UX
- [ ] 「該当なし」の提案と条件解除チップ
- [ ] 表/カードの切替(フォームでも達成可)
- [ ] エクスポートは現在条件で
テスト
- [ ] 条件維持のページング
- [ ] 並び替えの安定性
- [ ] a11y スモーク(フォーム/テーブル/サジェスト)
20. まとめ
- 検索・フィルタ・ソート・ページングを責務分担すると、性能とUXが整います。
- 自由語は Scout(Meilisearch/Elasticsearch)、確定条件は RDB で二段構え。
- ビューは
role="search"・fieldset/legend・aria-sort・aria-live・aria-busyで読みやすく操作しやすく。 - クエリ文字列で状態を表現し、ブックマーク/共有に強い設計に。
- 「該当なし」でも次の一歩を案内し、色に依存しない情報提示を徹底。
- インデックス/キャッシュ/ETag、そしてテストとa11yスモークで、現場でも壊れにくい検索基盤を育てましょう。わたしも応援しています。
