コンテンツにスキップ

検索結果の上限

業務アプリの検索には天井が必要です: 50,000 行を返しうるクエリは、 いつか必ず 50,000 行を要求されます。このレシピはその天井のサーバ契約 です — 超過を安価に検出する方法、トーストにもダイアログにも頼らず ユーザーに伝える方法、そして応答の 2 つの定石。JS ゼロで、 ライブ検索の形の 検索フォーム+サーバレンダリングのフラグメントだけで成立します。

わざと小さい上限 25 の背後に 90 件のダミー受注: 空検索は上限超過なので バナーが即座に表示されます。入力して上限以下に絞り込むと、バナーは それが説明していた切り詰めとともに消えます。モードのセレクトで 超過時の 2 分岐を切り替えられます。エンドポイントは api/recipes/result-cap/ 配下の契約のデモ実装です。

LIMIT cap + 1 — 上限より 1 行多く — でクエリします:

  • cap 件以下が返った — 全件を正確な件数とともに描画。
  • cap+1 件が返った — 検索は上限超過。実際の総数を知るために COUNT(*) を打たないこと(それこそ上限で避けたかったクエリです)。 件数は「cap+」と描画して分岐します:

モード A — 切り詰め(照会画面向け)

Section titled “モード A — 切り詰め(照会画面向け)”

先頭 cap 件を描画し(通常どおりページング — データグリッドのページネーション と組み合わせ)、結果リージョンの先頭に残り続ける警告バナーを置きます:

<div class="hc-alert" data-variant="warning" role="status" data-hc-result-cap>
<p class="hc-alert__title">先頭の 1,000 件を表示しています。</p>
<p class="hc-alert__body">1,000 件を超える受注が一致しました
(受注日の古い順)。続きを見るには条件を絞り込むか、全件を
CSV にエクスポートしてください。</p>
</div>
<p aria-live="polite">1,000+ 件</p>

バナーを誠実にする 2 つのルール:

  • ソート順を明示する。「先頭の 1,000 件」は、何の順で先頭なのかが 分かるまで無意味です。
  • 逃げ道を示す — 条件の絞り込み、または全件エクスポート (エクスポートは別枠の、ずっと大きい、通常は非同期の上限で動きます)。

data-hc-result-cap は契約マーカーであり、ビヘイビアは何も アタッチしません。hc validate がバナーを見つけるために使います。

モード B — ハードリジェクト(ワークキュー向け)

Section titled “モード B — ハードリジェクト(ワークキュー向け)”

チェックは同じで、超過時は行を一切描画しません:

<div class="hc-empty" data-hc-result-cap role="status">
<div class="hc-empty__media" aria-hidden="true">🔍</div>
<p class="hc-empty__title">1,000 件を超える案件が一致しました。</p>
<p class="hc-empty__description">1,000 件以下になるまで検索を
絞り込んでから、一覧を処理してください。</p>
</div>

画面の前提が「すべての案件を処理する」であるとき(承認キュー、 トリアージ受信箱)はこちらを選びます: 切り詰められたキューは、上限から あふれた案件をすべての担当者から静かに隠してしまいます。切り詰め モードが合うのは、ユーザーが 1 件を探している照会画面です。

リクエストレスポンス
検索、cap 件以下200 — 行+正確な件数、バナーなし
検索、上限超過(モード A)200 — 先頭 cap 件+警告バナー+「cap+」件数
検索、上限超過(モード B)200 — 行なし+hc-empty リジェクトブロック
検索、0 件200 — 通常の空状態

超過は常に 200 です — それはエラーではなくユーザーの状態であり、 no-JS のフルページも同じ分岐で描画します。(そもそも htmx ≥ 2 は 非 2xx をスワップしないため、前の結果が画面に残ってしまいます — いまの結果を説明するバナーには正反対の挙動です。)

プログレッシブエンハンスメント

Section titled “プログレッシブエンハンスメント”

フォームの action は JavaScript なしでフルページ GET として動きます。 サーバは HX-Request で分岐し(フラグメント/フルページ)、どちらにも 同じバナー/リジェクトのマークアップを描画します。このレシピには ビヘイビアが一切登場しません。

  • バナーは role="status" であって role="alert" ではありません — 切り詰めは気づいてもらうべき持続的な状態であり、割り込みでは ありません。status は丁寧に読み上げ、バナーは読み返せるように 残ります。
  • 件数行の aria-live="polite" はバナーの外に置き、ページ送りの 読み上げがバナーと独立するようにします。
  • トーストは不可(状態が続いているのに消えます)、モーダルダイアログも 不可(ブロックした挙句、状態を画面に残さず消えます)。
  • 上限の選び方。 対話的な検索画面の相場は 500〜2,000、ページ サイズは 20〜50(20/50/100 から選択可)。スナップショットページャーに つながる画面は低めに(500〜1,000) — 上限がスナップショットのキー リストの長さを抑えます。
  • 件数要素はデータグリッドのページャーの out-of-band #rows-status と 自然に対になります — 超過時はどのページでも「1,000+」のままです (1–100 / 1,000+)。