コンテンツにスキップ

データグリッドページ

業務の画面が最終的に必要とするレイアウトです。周囲の要素 — タイトル、 条件チップ、ツールバー、ページャ — は動かず、グリッドが残りの高さを すべて取るので、スクロールするのはグリッドだけ(縦横とも)。その上 でヘッダーは多段で固定、先頭の列も固定されます。検索条件の入力は ダイアログに置きます。20 個の条件は、それが絞り込む対象のデータの上に 置くものではないからです。

これは CRUD リストページの 兄弟です。あちらは一覧画面に必要な契約(ページング、一括操作、 ダイアログ編集、Undo)の話で、こちらはグリッドが画面の主役になった ときにそれらが収まるの話です。この枠を育てる操作の全体は データグリッドガイドが 地図化しています。

Order desk
  • Ship date
  • Order
Shown columns
    OrderOrderShipmentPeek
    Ordered onCustomerItemShip dateCarrier service levelAmount

    Filters

    Ship date

    Save view

    Who can see it

    Captures the conditions, the sort and the column set — not the page you are on.

    実寸のプレビューを開く ↗

    全高の画面をドキュメントのカラムに収めて見せるのは、考え方のであって 考え方そのものではありません。要点は「クロームが固定で、グリッドが ビューポートの残り全部を取る」ことだからです。上のプレビューはここに 収まるよう縮めたもので、リンクを開くと同じテンプレートが画面を独り占め します。

    グリッドをスクロールしてみてください。ツールバーとヘッダー行はその場に 留まり、先頭 2 列も留まり、ページ自体は動きません。Filters… を開いて パネルをスクロールすると、本文だけが動き、Apply は押せる位置に残ります。 タイトルの横のメニューが、いま適用されている保存ビューの名前を述べます — ビューの切り替えは画面から 1 操作です。

    高さ固定か、ページをスクロールさせるか

    Section titled “高さ固定か、ページをスクロールさせるか”

    キットは両方を出荷していて、高さ固定が自動的に優れているわけでは ありません。これは**業務系(オペレーショナル)**の形です。SAP Fiori のリストレポート、Salesforce のリストビュー、ServiceNow のリスト、 AG Grid、Ant Design の scroll.y — バックオフィス画面がここに収束する のは、1 時間キューを捌く間、ヘッダー・ツールバー・ページャがその場に 留まる必要があるからです。

    ページスクロールはコンテンツ系の形で、それ以外のほぼ既定です。 GitHub、Jira のバックログ、Linear、そしてすべてのモバイル表示。作るのに コストがかからず、ブラウザ自身の挙動(Find、Back でのスクロール復元、 スマホのアドレスバー自動格納)がそのまま効きます。

    高さ固定を選ぶのは、次の 4 つがすべて成り立つときだけです。

    1. **画面がアプリのフレームであること。**ページ自体がスクロールしては いけません。さもないとスクロールバーが 2 本になり、読んでいる最中に グリッドが動きます。hc-shellhc-fill はそのためにあります。
    2. ブレークポイント以下の逃げ道があること。hc-shell60rem 未満で普通のスクロールページになります。スマホで固定フレームにすると データ領域が数行しか残りません。
    3. **印刷でスクロールポートの上限を外すこと。**紙にスクロールはあり ません。上限が隠していた分は、何の断りもなく消えます。オプトインの 印刷シートがこれを 行うので、読み込んでください。
    4. **行がページングされていること(無限ではない)。**無限リストの上に 固定ビューポートを置くと、位置について正直でいるために仮想化が要り ます。ページと件数なら要りません。

    1 つでも成り立たないなら、ページをスクロールさせ、 --hc-datagrid-max-height は既定の 70vh のままにしてください。 固定ヘッダーだけでも利点の大半は手に入ります。

    <main class="hc-shell__main app-main">
    <div class="page">
    …タイトル・条件・ツールバー…
    <form class="hc-fill" id="order-selection">
    <div class="hc-datagrid hc-fill" id="orders"></div>
    </form>
    </div>
    </main>
    /* hc-shell__main をページカラムを収める行にする */
    .app-main {
    display: flex;
    min-block-size: 0;
    min-inline-size: 0;
    }
    /* ページカラム:固定の領域が並び、残りは「埋める」と言った要素が取る */
    .page {
    display: flex;
    flex: 1;
    flex-direction: column;
    min-block-size: 0;
    min-inline-size: 0;
    gap: var(--hc-space-3);
    }

    .hc-fill はカラムとグリッドの間にあるすべての要素に付けます — 包んでいる <form> も含めてです — そしてグリッド自身にも付けます。 グリッドに付いたときは --hc-datagrid-max-height を既定の 70vh から 100% に切り替えます。

    子孫セレクタではなくクラスにしているのは意図的です。

    • **1 ページに複数のグリッドが載り得ます。**詳細画面ではヘッダ・明細・ 履歴のグリッドが積み重なります。残りの高さを取ってよいのは「そう 言った」1 つだけで、他は自分の上限を保つべきです。 .page > form > .hc-datagrid のような規則は、構造がたまたま一致した グリッドを埋めてしまいます。
    • **構造は変わります。**枠線を付けるためにグリッドを <div> で包んだ 瞬間、子孫規則は当たらなくなります。しかも静かに、そして症状 (グリッドではなくページがスクロールする)は変更箇所と離れた場所に 出ます。

    **2 つの最小値が両方とも要で、軸ごとに 1 つずつです。**flex 子は コンテンツより小さくなれず、データグリッドは両軸とも大きいからです — テーブルは inline-size: max-content、行数はサーバーが返しただけあり ます。

    • min-block-size: 0 を落とすと、グリッドが全行の入る高さまで 伸びてページが縦にスクロールします。ツールバーが流れていき、 ヘッダー行の固定も効かなくなります。
    • min-inline-size: 0 を落とすと、カラムがテーブルの幅まで広がり、 横方向のスクロールポートが hc-shell__main になります。右に スクロールするとタイトルもツールバーも一緒に動く — グリッド自身の スクロールポートが防ぐはずだった、まさにその状態です。

    後者は見落としやすい部類です。テーブルが画面幅を超えるまでは、 見た目には何も問題がないからです。

    --hc-datagrid-max-height の既定値は 70vh で、スクロールするページの 中に置くグリッドにはそれが適切です。ページそのものになる グリッドでは 100% にします。

    hc-shell は自分の担当分を既に果たしています。__mainoverflow: auto かつ min-block-size: 0 なので、フレームがビュー ポートを押し広げることはありません。

    シェルのブレークポイント以下では

    Section titled “シェルのブレークポイント以下では”

    60rem 未満では hc-shell は意図的に普通のスクロールするページに 変わります(display: blockoverflow: visible)。高さ固定のアプリ フレームはスマートフォンでは正しい形ではないからです。するとグリッド の親に確定した高さがなくなるため 100% は何も制限せず、全行が実寸で 描画されてしまいます。ビューポート基準の上限に戻してください。

    @media (width <= 60rem) {
    .page > .hc-datagrid {
    --hc-datagrid-max-height: 70vh;
    }
    }

    URL は実アプリの形にしてあります。JavaScript が動く前にグリッドが 埋まっているよう、最初のページの行はサーバーで描画してください。

    <body>
    <div class="hc-shell">
    <header class="hc-shell__header">
    <button class="hc-button hc-shell__toggle" data-variant="ghost" type="button"
    data-hc-shell-toggle aria-label="Open navigation"></button>
    <strong>Order desk</strong>
    </header>
    <nav class="hc-shell__sidebar" aria-label="Primary">
    <a href="/orders" aria-current="page">Orders</a>
    <a href="/shipments">Shipments</a>
    <a href="/customers">Customers</a>
    </nav>
    <main class="hc-shell__main app-main">
    <div class="page">
    <!-- Identity row: the heading names the screen, the menu names
    the saved view currently applied. -->
    <div class="hc-cluster" style="justify-content: space-between;">
    <div class="hc-cluster">
    <h1>Orders</h1>
    <button class="hc-button" data-variant="ghost" type="button"
    id="view-trigger" popovertarget="views">
    <span>Overdue shipments</span>
    <span class="hc-badge" data-variant="warning">Modified</span>
    <span aria-hidden="true"></span>
    </button>
    <div class="hc-menu" id="views" popover role="menu"
    aria-labelledby="view-trigger">
    <div role="group" aria-labelledby="views-pinned">
    <span class="hc-menu__label" id="views-pinned">Pinned</span>
    <a class="hc-menu__item" role="menuitemradio" aria-checked="true"
    href="/orders?view=overdue">Overdue shipments</a>
    <a class="hc-menu__item" role="menuitemradio" aria-checked="false"
    href="/orders?view=unapproved">Awaiting approval</a>
    </div>
    <hr class="hc-menu__separator">
    <a class="hc-menu__item" role="menuitemradio" aria-checked="false"
    href="/orders">Show everything</a>
    <hr class="hc-menu__separator">
    <!-- Discarding a tweak is a link back to the view's own
    URL — a form reset would restore the modified state. -->
    <a class="hc-menu__item" role="menuitem"
    href="/orders?view=overdue">Reset to saved conditions</a>
    <a class="hc-menu__item" role="menuitem" href="/views">Manage views…</a>
    </div>
    </div>
    <button class="hc-button" data-variant="secondary" type="button"
    onclick="document.getElementById('filters').showModal()">Filters (2)</button>
    </div>
    <!-- Applied conditions: one chip per condition, each opening the
    panel; each remove link is this URL minus that one param. -->
    <div class="hc-filterbar">
    <ul class="hc-filterbar__list">
    <li class="hc-filterbar__item">
    <button class="hc-filterbar__chip" type="button"
    onclick="document.getElementById('filters').showModal()">
    <span class="hc-filterbar__label">Ship date</span>
    <span class="hc-filterbar__op">from</span>
    <span class="hc-filterbar__value">start of this week (2026-08-10)</span>
    </button>
    <a class="hc-filterbar__remove" href="/orders?f-carrier=road"
    aria-label="Remove Ship date filter">×</a>
    </li>
    <!-- …one chip per applied condition (here: Carrier)… -->
    </ul>
    <a class="hc-filterbar__clear" href="/orders">Clear all</a>
    </div>
    <!-- Toolbar: actions on the DATA only. Selection-scoped
    actions are in their own bar below; navigation is under
    the grid. -->
    <div class="hc-toolbar" role="toolbar" aria-label="Order actions">
    <!-- Sort is a read-out first: the sorted column is usually
    scrolled out of view. See recipes/datagrid-sort. -->
    <button class="hc-button" data-variant="secondary" type="button"
    id="sort-trigger" popovertarget="sort-panel">
    Sort (2): Ship date ↓, Order ↑
    </button>
    <!-- The columns entry point. See recipes/datagrid-columns:
    a chooser form GETs the grid with repeated cols= params,
    in the order the rows were dragged into. -->
    <button class="hc-button" data-variant="ghost" type="button"
    id="columns-trigger" popovertarget="columns-panel">
    Columns (7 of 12)
    </button>
    <button class="hc-button" data-variant="ghost" type="button"
    data-hx-get="/orders/rows" data-hx-target="#order-rows">Refresh</button>
    <!-- Export carries the row count in its label and the current
    conditions in its href — a download means this question,
    not the rows on screen. -->
    <a class="hc-button" data-variant="ghost"
    href="/orders.csv?f-ship-from=@week-start&amp;f-carrier=road&amp;sort=-ship,order">Export 5,000 rows</a>
    <hr role="separator" aria-orientation="vertical">
    </div>
    <!-- Selection-scoped actions: installDatagridActions() reveals
    this bar when rows are ticked and hides it again. -->
    <div class="hc-toolbar" role="toolbar" aria-label="Selected orders"
    data-hc-datagrid-actions="#orders" hidden>
    <strong data-hc-datagrid-count></strong>
    <div class="hc-button-group" role="group" aria-label="Approval">
    <button class="hc-button" data-variant="ghost" type="submit"
    form="order-selection" name="action" value="approve">Approve</button>
    <button class="hc-button" data-variant="ghost" type="submit"
    form="order-selection" name="action" value="reject">Reject</button>
    </div>
    </div>
    <!-- The grid takes the rest. One form so checked ids serialize. -->
    <form class="hc-fill" id="order-selection" method="post" action="/orders/bulk">
    <div class="hc-datagrid hc-fill" id="orders" data-hc-zebra data-hc-datagrid-pending>
    <div class="hc-datagrid__scroll">
    <table class="hc-datagrid__table">
    <thead class="hc-datagrid__head">
    <tr>
    <th class="hc-datagrid__headcell" data-frozen rowspan="2" scope="col"
    style="--hc-datagrid-left: 0;">
    <!-- Select-all: no name — it must never serialize. -->
    <input type="checkbox" class="hc-checkbox" aria-label="Select all">
    </th>
    <th class="hc-datagrid__headcell" data-frozen data-frozen-edge rowspan="2"
    scope="col" style="--hc-datagrid-left: 2.5rem;">Order</th>
    <th class="hc-datagrid__headcell" colspan="3">Order</th>
    <th class="hc-datagrid__headcell" colspan="4">Shipment</th>
    </tr>
    <tr>
    <th class="hc-datagrid__headcell" scope="col">Ordered on</th>
    <th class="hc-datagrid__headcell" scope="col">Customer</th>
    <th class="hc-datagrid__headcell" scope="col">Item</th>
    <th class="hc-datagrid__headcell" scope="col">Ship date</th>
    <!-- A long label reads down a narrow column instead of
    widening it. `sideways` rotates the whole line. -->
    <th class="hc-datagrid__headcell" scope="col"
    data-orientation="vertical">Carrier service level</th>
    <th class="hc-datagrid__headcell" scope="col" data-numeric>Quantity</th>
    <th class="hc-datagrid__headcell" scope="col" data-numeric>Amount</th>
    </tr>
    </thead>
    <tbody class="hc-datagrid__body" id="order-rows">
    <tr class="hc-datagrid__row" id="row-4901">
    <td class="hc-datagrid__cell" data-frozen style="--hc-datagrid-left: 0;">
    <input type="checkbox" class="hc-checkbox" name="ids" value="4901"
    aria-label="Select order SO-4901">
    </td>
    <th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row"
    style="--hc-datagrid-left: 2.5rem;">SO-4901</th>
    <td class="hc-datagrid__cell">2026-07-14</td>
    <td class="hc-datagrid__cell">Northwind</td>
    <td class="hc-datagrid__cell">Bearing assembly 1000</td>
    <td class="hc-datagrid__cell" data-editable data-col="ship"
    data-value="2026-08-14">2026-08-14</td>
    <td class="hc-datagrid__cell">Road</td>
    <td class="hc-datagrid__cell" data-numeric>18</td>
    <td class="hc-datagrid__cell" data-numeric>2,610</td>
    </tr>
    <!-- …the rest of the page's rows… -->
    </tbody>
    </table>
    </div>
    </div>
    </form>
    <!-- Navigation under the data it moves through. -->
    <div class="hc-toolbar" role="toolbar" aria-label="Rows">
    <!-- Where you ARE at the start, where you GO at the end. -->
    <span aria-live="polite">4,901–4,940 of 5,000</span>
    <span data-hc-spacer="true"></span>
    <nav class="hc-pagination" aria-label="Pages">
    <a class="hc-pagination__item" data-hc-rel="prev" href="/orders?page=122">Previous</a>
    <a class="hc-pagination__item" data-hc-rel="next" href="/orders?page=124">Next</a>
    </nav>
    </div>
    </div>
    </main>
    </div>
    <!-- Filter panel. One form around header + body (the BODY is what
    scrolls); the footer sits OUTSIDE the form so Cancel can be its
    own <form method="dialog">, and Apply reaches the form via the
    `form` attribute. A `formmethod="dialog"` submit button inside
    the form would die the moment the form is enhanced with
    `data-hx-get`: htmx cancels the native submit, then refuses a
    non-HTTP formmethod. -->
    <dialog class="hc-dialog" id="filters" aria-labelledby="filters-title"
    style="--hc-dialog-max-width: 52rem;">
    <form method="get" action="/orders" id="filters-form">
    <!-- The panel edits conditions and closes the composition by
    naming it. Recall lives on the screen, not in here. -->
    <div class="hc-dialog__header">
    <div class="hc-cluster" style="justify-content: space-between;">
    <h2 class="hc-dialog__title" id="filters-title">Filters</h2>
    <div class="hc-cluster">
    <button class="hc-button" data-size="sm" type="button"
    data-hx-put="/views/Overdue%20shipments"
    data-hx-include="closest form"
    data-hx-target="#views">Update</button>
    <!-- Opens the save dialog: name, scope, make default. -->
    <button class="hc-button" data-size="sm" type="button">Save as new…</button>
    </div>
    </div>
    </div>
    <div class="hc-dialog__body">
    <!-- data-align="start": a three-row textarea must not stretch its
    neighbour into a tall empty box. -->
    <div class="hc-grid" data-align="start" style="--hc-grid-min: 18rem;">
    <div class="hc-field">
    <label class="hc-field__label" for="f-order">Order number</label>
    <!-- Value + operator share one bordered surface and one ring;
    the operator is secondary, so it renders quiet. -->
    <div class="hc-input-group">
    <input class="hc-input" type="text" id="f-order" name="f-order">
    <select class="hc-select" data-quiet name="op-order" aria-label="Order number operator">
    <option value="eq">equals</option>
    <option value="contains">contains</option>
    </select>
    </div>
    </div>
    <!-- Pasted lists become repeated params — one value per line. -->
    <div class="hc-field" data-span="full">
    <label class="hc-field__label" for="f-item">Item codes</label>
    <textarea class="hc-input" id="f-item" name="f-item" rows="3" data-hc-multi="lines"
    placeholder="One per line — paste a column from a spreadsheet"></textarea>
    </div>
    <!-- …the remaining conditions… -->
    <!-- data-applied marks the fields that are currently set. -->
    <div class="hc-field" data-applied>
    <span class="hc-field__label" id="ship-label">Ship date</span>
    <div class="hc-cluster" role="group" aria-labelledby="ship-label">
    <input class="hc-input" type="date" name="f-ship-from" aria-label="Ship date from">
    <span aria-hidden="true"></span>
    <input class="hc-input" type="date" name="f-ship-to" aria-label="Ship date to">
    </div>
    </div>
    </div>
    </div>
    </form>
    <div class="hc-dialog__footer">
    <form method="dialog"><button class="hc-button">Cancel</button></form>
    <button class="hc-button" data-variant="primary" type="submit"
    form="filters-form">Apply</button>
    </div>
    </dialog>
    </body>
    .page {
    display: flex;
    flex: 1;
    flex-direction: column;
    min-block-size: 0;
    min-inline-size: 0;
    gap: var(--hc-space-3);
    }
    /* hc-shell のブレークポイント以下ではシェルが普通のスクロールページに
    なるので、埋めるグリッドには測る相手の高さが無くなります。 */
    @media (width <= 60rem) {
    .hc-datagrid.hc-fill {
    --hc-datagrid-max-height: 70vh;
    }
    }

    連鎖が重要です。ページカラムからグリッドまでの間にある要素はすべて hc-fill を持ちます — 選択用の <form> も含めてです。1 つでも欠けると、 その軸はグリッドではなくページをスクロールさせます。

    ビューはパネルではなく画面に置く

    Section titled “ビューはパネルではなく画面に置く”

    一覧画面が答える問いは 4 つ — いま何を見ているかどう絞ったかどの順かどの列か — で、それぞれに置き場所を 1 つだけ与えると最も 読みやすくなります。1 つ目はタイトルの横で、ラベルが適用中のビュー名 そのものであるメニューが答えます。

    • 保存ビューは名前の付いた URL です。つまり呼び出しはフィルタ編集 ではなくナビゲーションであり、ブックマークは「アドレスバーを編集する ダイアログ」の中には置きません。パネルの奥に置くと、画面で最も頻度の 高い操作が 4 手になります。
    • 項目は role="menuitemradio" です。適用されているビューは常に 1 つで、Show everything がその「どれでもない」選択肢 — 既定ビュー が素の URL をリダイレクトしているときの戻り道になります。
    • 同時に本物の <a href> なので、ビューはブックマークでき、中クリックで 開け、JavaScript なしでも動きます。
    • ピン留め、次に最近使った順、最後に Manage views…。チームが 2〜3 個 に標準化しているならチップやタブでも構いませんが、30 個になっても壊れ ない形はメニューです。

    パネルに残すのは条件を組み立てる終端の操作UpdateSave as new… — だけです。これで Undo が離れた場所に 2 つ並ぶことが なくなり、Cancel 1 つになります。そして「元に戻す」が type="reset" ボタンではなくビュー自身の URL へのリンクである理由も ここにあります。ネイティブのリセットが戻すのはサーバーが描画した値、 つまり変更後の状態そのものなので、取り消しを約束したコントロールだけが 取り消せない、という状態になっていました。

    どのコントロールをどこに置くか

    Section titled “どのコントロールをどこに置くか”

    一覧画面には 4 種類のコントロールが集まります。同じ帯を共有した瞬間、 それらは「ごちゃごちゃ」として読まれます。何を変えるかで家を 1 つ ずつ与えます。

    種類置き場所
    答えの形を変えるビュー、Filters、Sort、Columnsタイトルの横。属しているビューの隣
    答えを読み出す条件チップ、件数その下の専用行
    データに対する操作Refresh、Import、Exportツールバー
    選択に対する操作Approve、Reject、選択した件を開く行がチェックされたとき現れるバー
    データの中を移動するページャ、行番号へ移動グリッドの下(移動が起きる場所)

    移動用の帯の中でも位置は意味を持ちます。いまどこにいるか4,901–4,940 of 5,000)は先頭に置きます。読み出しであり、それが 指している固定の同一性列も同じ側にあるからです。どこへ行くか(ページャ) は末尾に置きます。理由は好みではなく手の動きで、グリッドをスクロール した直後のポインタは既に末尾側の縁(スクロールバーのある側)にあり、 Next はこの帯の中で最も多く押されるからです。論理プロパティなので、 RTL では両方が自動で入れ替わります。

    このうち 2 つは明記する価値があります。

    • **選択操作はツールバーに置きません。**Approve と Reject が効くのは 選択が存在する数分だけで、残りの時間はただ読まれ続けます。 installDatagridActions() は行がチェックされたときにバーを出し、 外れたら隠します。現れるバーの方が、灰色になるボタンより良い合図 です。無効化されたボタンは何も説明しません。
    • **Filters・Sort・Columns は 1 つの群です。**同じ問い(いま何を見て いるか)に答えるので、Filters だけタイトル横・Sort と Columns は ツールバー、と分けると実際以上に画面が混雑して見えます。

    ソートと列にも専用のコントロール

    Section titled “ソートと列にも専用のコントロール”

    4 つの問いの残り 2 つ — どの順かどの列か — にも、フィルタの入口の 隣にコントロールを 1 つずつ与えます。

    • Sort はラベルで集合全体を述べ(Sort (2): Ship date ↓, Order ↑)、 並べ替え可能な順序付きリストを開きます。ヘッダーだけでは答えられない 理由は datagrid-sort にあります。
    • Columnsチューザー を開きます。レシピは既にありました。無かったのは画面側の入口です。 件数はラベルに載せます(Columns (7 of 12))。探している列が無い グリッドは、データが無いグリッドと見分けが付かないからです。

    **列構成は条件ではなく設定です。**画面や端末をまたいでユーザーに付いて 回るので、URL に毎回書かせるのではなくユーザー単位で保存します。ただし 列を名指しするリンクは常に勝ちます。共有が機能するのはそのためです。

    URL → ユーザー設定 → アプリ既定

    保存ビューは列構成を固定してもよい(「出荷チェック」は普通、条件 その仕事のための列を意味します)。固定した場合は、適用したときに 列が目に見えて変わり、戻る道も示します。

    タイトルの下の帯は装飾ではありません。いま何で絞り込まれているかで あり、各チップがその条件を変える手段です。1 つは相対条件で、 start of this week (2026-08-10) のように保存された式と今日の解決値の 両方を表示します。条件が推測にならず、それを使った保存ビューが来週も 正しいままである理由です。

    テンプレートが示す帰結が 3 つあります。

    • **エクスポートがクエリを持ち歩きます。**ボタンは Export 5,000 rows と件数を述べ、href には同じ条件と列が載ります。ダウンロードは画面の 行ではなくこの問いを意味します。
    • **ソートは条件と一緒に運ばれます。**ソートパネルがセット全体を 1 つの sort=-ship,order パラメータに直列化するので、Apply でも 維持され、保存ビューにも含まれます。
    • **調整したビューはそう言います。**ビューメニューのラベルが Modified バッジを持ちます。ビューを適用してから条件を 1 つ変える のは保存ビューで最もよく行われる操作であり、バッジが正直でいられるの は比較の起きる場所 — 比較対象の名前の隣 — だけだからです。

    検索パネルは入力されるより読まれる回数の方がずっと多いので、走査 しやすさを基準に組みます。

    • **ラベルはコントロールの上に。**走査も入力も速く、ラベル長にも強い です(“Buyer” と 発注者コード の差は、固定幅のラベル列では吸収でき ません)。
    • フィールドは行の上端に揃えますhc-griddata-align="start")。3 行のテキストエリアが隣を縦長の空箱に引き 伸ばすのを止めます。
    • 複数行のものは 1 行を占有しますdata-span="full")。 hc-gridauto-fit なので、どのフィールドが隣り合うかは幅で 変わります。特定の組み合わせに依存してはいけません。
    • 演算子は従属的に(input group 内の select に data-quiet)。 ほとんどの行が使うのは equals であり、値と同じ強さで描くと「何が 設定されているか」を探す手間が倍になります。
    • 適用中のフィールドには印を付けます(フィールドに data-applied)。 8 行読まなくても「いま何が設定されているか」が一目で分かります。点は 走査の補助であって、通知はデータの上の条件バーが担います。
    • **語彙は 1 つ、Apply。**ここだけ Search、ソートパネルは Apply、は やめます。
    • Cancel は独立した <form method="dialog">。ネイティブで JS 不要の 閉じ方で、remote-dialog の契約が定める定石そのものです。フィルタ フォーム内の formmethod="dialog" 送信ボタンにはしません — フォームを data-hx-get で強化した瞬間に死ぬからです。htmx は管理下フォームの ネイティブ送信を preventDefault したうえ、HTTP 動詞でない formmethod のリクエスト発行を拒否するので、何も起きないボタンに なります。そのため footer はフォームの外に置き、Apply は form 属性でフォームに届きます。

    アイコンも画面と同じ規則に従います。普遍でないものはアイコン+ ラベル、アイコンのみは閉じる・オーバーフロー・ページャの矢印に限り、 件数はバッジではなくラベルに載せます(Filters (3))。 アイコン を参照してください。

    レイアウトの決まりごとには系があります。一括操作が 15 通りの理由で 失敗するまで、たいてい見落とされます。

    データ量に応じて高さが伸びるものは、スクロール領域かオーバーレイに 置く。クロームには置かない。

    クロームはグリッドの高さが差し引かれる相手です。グリッドの上に描いた 一括エラーのレポートO(理由の数) なので、ノート PC ではグリッドをゼロまで潰します — しかも「直しに行け」と言っている当の行を隠して。

    そこでクロームが持つのは1 行です。

    <div id="bulk-report" aria-live="polite">
    <div class="hc-alert" data-variant="warning" role="status">
    <p class="hc-alert__body">
    <strong>12 of 40 rows could not be updated.</strong>
    <a href="/orders?f-last-result=failed">Show only failed (12)</a> ·
    <a href="/orders/bulk/report">Review reasons</a>
    </p>
    </div>
    </div>
    /* 保険:サーバーの内容は無制限、領域は有限。 */
    #bulk-report {
    max-block-size: min(25vh, 12rem);
    overflow: auto;
    }

    この行は移動手段も持ちます。5,000 行に散った 12 件の失敗は待ち行列 だからです。

    <a href="#row-4903">Previous</a>
    <span>Error 3 of 12 — row 137</span>
    <a href="#row-5012">Next</a>

    行を名指しするのは ID の本物のフラグメントリンクで、隣に序数を 表示します。installDatagrid() がフラグメントの指す行にアクティブ セルを着地させるので、「次のエラーへ」はクライアント状態ゼロの フォーカス移動になり、Back も効きます。ツールバーの行番号へ移動?goto=137)は口頭で伝えられた番号のためのもので、その番号が今 どのページにあるかはサーバーが解決します。

    詳細は、伸びることが既に扱われている場所に置きます。失敗した行は data-attention="error" と自分のメッセージ行を持ち(データそのもの なのでスクロールします)、理由別の内訳はグリッドの横のドッキング パネルで開きます — サイドパネルが使うのは横の空間で、このレイアウトに 余っているのは横です。モーダルが正しいのは、何も適用されておらず、 ユーザーが判断を返す番のときだけです。

    同じ規則は、サーバーが埋める他のものにも当てはまります。検証の要約、 取り込みのプレビュー、「3 件の条件が適用できませんでした」の通知。

    リージョンコンポーネントサーバー契約
    アプリフレームhc-shell
    適用中の条件hc-filterbardatagrid-filter — 条件 1 つにつきチップ 1 つ。各チップが自分のエディタを開き、削除リンクは 1 パラメータだけ落とす
    ツールバーhc-toolbar + installToolbar()—(Tab ストップ 1 つ、中は矢印キー)
    ソートツールバーのコントロール + installSortList()datagrid-sort — トリガーが集合全体を述べ、パネルが並べ替える(sort=-ship,order
    選択時のアクションinstallDatagridActions()datagrid-bulk-actions、大量失敗時は datagrid-bulk-errors
    ページャhc-paginationdatagrid-pager
    グリッドhc-datagridf-<col> パラメータは datagrid-filter
    インライン編集data-editable + エディタ <template>datagrid-edit-errors(422 と確認付き警告)、datagrid-edit-conflict(409)
    行 → 詳細同一性の列の <a href> + installRowLink()row-detail — 「一覧に戻る」はこの URL + #row-<id>選択した N 件を開くは順序付きスナップショットを辿る
    検索パネルhc-dialog + hc-grid + hc-input-groupdatagrid-filter
    保存ビュータイトル横の hc-menu + パネルの Update / Save as new…saved-views — 適用は GET リンク、PUT /views/<name> がその場更新、変更済みの比較はサーバーの仕事
    複数値の入力<textarea data-hc-multi="lines">datagrid-filter — 貼り付けた一覧が繰り返しパラメータになる
    相対日付@week-start のような条件値datagrid-filter — 式のまま保存し、サーバーが解決
    エクスポート現在のクエリを持つリンクdatagrid-filter — 同じ条件・同じ列・全ページ
    列の構成・幅ツールバーのコントロールがチューザーを開くdatagrid-columnscols= の繰り返し(ドラッグした順序のまま)。幅は datagrid-prefs
    • **ヘッダーの段数。**固定オフセットを計測しているのは 3 段まで です(--hc-datagrid-head-1-h-2-h)。4 段目には規則の追加が 必要です。
    • 長い列名。data-orientation="vertical" はラベルを縦組みにして、 狭い列を広げずに読ませます。sideways は行全体を逆方向に回します。
    • **列の色帯。**列を色づけるならセルに data-highlight、ヘッダーの帯 ならそのヘッダーセルで --hc-datagrid-head-bg を上書きします — グリッドは着せ替えられる前提で作られています。
    • **検索パネルをサーバーから。**条件セットがユーザーごとに違う、あるいは 取得する価値があるほど大きい場合は、インラインの <dialog>remote-dialog レシピに置き換えてください。
    • **JavaScript なし。**パネルはただの <form method="get">、ページャは ただのリンクなので、JavaScript がなくても絞り込みとページ送りは動き ます。グリッドも描画されます。必要になるのはインライン編集と ツールバーの矢印キー移動だけです。
    • **密度。**シェルに data-density="compact" を付けると画面全体が 締まります。タブレット利用があるならタッチターゲットを確認して ください(テーマとランタイム軸)。