検索結果の上限
業務アプリの検索には天井が必要です: 50,000 行を返しうるクエリは、 いつか必ず 50,000 行を要求されます。このレシピはその天井のサーバ契約 です — 超過を安価に検出する方法、トーストにもダイアログにも頼らず ユーザーに伝える方法、そして応答の 2 つの定石。JS ゼロで、 ライブ検索の形の 検索フォーム+サーバレンダリングのフラグメントだけで成立します。
わざと小さい上限 25 の背後に 90 件のダミー受注: 空検索は上限超過なので
バナーが即座に表示されます。入力して上限以下に絞り込むと、バナーは
それが説明していた切り詰めとともに消えます。モードのセレクトで
超過時の 2 分岐を切り替えられます。エンドポイントは
api/recipes/result-cap/ 配下の契約のデモ実装です。
cap+1 チェック
Section titled “cap+1 チェック”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 件を探している照会画面です。
サーバレスポンス契約
Section titled “サーバレスポンス契約”| リクエスト | レスポンス |
|---|---|
| 検索、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 で分岐し(フラグメント/フルページ)、どちらにも
同じバナー/リジェクトのマークアップを描画します。このレシピには
ビヘイビアが一切登場しません。
アクセシビリティ
Section titled “アクセシビリティ”- バナーは
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+)。
- ライブ検索 — この 契約の手前にある検索フォーム。
- データグリッドのページネーション — 上限内の結果セットのページング。
- アラートと エンプティ — サーバが 描画する 2 つのブロック。