php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【現場完全ガイド】Laravelの検索・一覧・フィルタUX――Eloquent/Scout/Meilisearch、ページネーション、ソート、ファセット、アクセシブルなテーブル&カード表示

この記事で学べること(要点)

  • 検索・一覧・フィルタの情報設計と、Eloquent/クエリビルダ/Scout の使い分け
  • ソート/並び替えのホワイトリスト化、ページネーション、クエリ文字列の永続化
  • Meilisearch/Elasticsearch 連携(Laravel Scout)と、全文検索×RDB の役割分担
  • アクセシブルな結果一覧:テーブル/カードの構造、aria-sortaria-livearia-busy、キーボード到達性
  • ファセット(絞り込み)UI、検索ボックス(コンボボックス/APG準拠)、「該当なし」時の導線
  • エクスポート、ブックマーク共有、SEO/パフォーマンス、テスト観点まで

想定読者(だれが得をする?)

  • Laravel 初〜中級エンジニア:安定した一覧・検索機能を安全にスケールさせたい方
  • SaaS/EC/メディアのテックリード:サーバ負荷/検索精度/UX/アクセシビリティを横断的に整えたい方
  • デザイナー/ライター/QA:テーブルやカードの読み上げ、フィルタ/ソートの操作手順を標準化したい方

アクセシビリティレベル:★★★★★

role="search"・コンボボックス(APG準拠)・aria-sortaria-livearia-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/legendaria-sortaria-livearia-busy読みやすく操作しやすく
  • クエリ文字列で状態を表現し、ブックマーク/共有に強い設計に。
  • 「該当なし」でも次の一歩を案内し、色に依存しない情報提示を徹底。
  • インデックス/キャッシュ/ETag、そしてテストとa11yスモークで、現場でも壊れにくい検索基盤を育てましょう。わたしも応援しています。

参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)