データグリッドページ
業務の画面が最終的に必要とするレイアウトです。周囲の要素 — タイトル、 条件チップ、ツールバー、ページャ — は動かず、グリッドが残りの高さを すべて取るので、スクロールするのはグリッドだけ(縦横とも)。その上 でヘッダーは多段で固定、先頭の列も固定されます。検索条件の入力は ダイアログに置きます。20 個の条件は、それが絞り込む対象のデータの上に 置くものではないからです。
これは CRUD リストページの 兄弟です。あちらは一覧画面に必要な契約(ページング、一括操作、 ダイアログ編集、Undo)の話で、こちらはグリッドが画面の主役になった ときにそれらが収まる枠の話です。この枠を育てる操作の全体は データグリッドガイドが 地図化しています。
ライブテンプレート
Section titled “ライブテンプレート”Orders
全高の画面をドキュメントのカラムに収めて見せるのは、考え方の絵であって 考え方そのものではありません。要点は「クロームが固定で、グリッドが ビューポートの残り全部を取る」ことだからです。上のプレビューはここに 収まるよう縮めたもので、リンクを開くと同じテンプレートが画面を独り占め します。
グリッドをスクロールしてみてください。ツールバーとヘッダー行はその場に 留まり、先頭 2 列も留まり、ページ自体は動きません。Filters… を開いて パネルをスクロールすると、本文だけが動き、Apply は押せる位置に残ります。 タイトルの横のメニューが、いま適用されている保存ビューの名前を述べます — ビューの切り替えは画面から 1 操作です。
高さ固定か、ページをスクロールさせるか
Section titled “高さ固定か、ページをスクロールさせるか”キットは両方を出荷していて、高さ固定が自動的に優れているわけでは
ありません。これは**業務系(オペレーショナル)**の形です。SAP Fiori
のリストレポート、Salesforce のリストビュー、ServiceNow のリスト、
AG Grid、Ant Design の scroll.y — バックオフィス画面がここに収束する
のは、1 時間キューを捌く間、ヘッダー・ツールバー・ページャがその場に
留まる必要があるからです。
ページスクロールはコンテンツ系の形で、それ以外のほぼ既定です。 GitHub、Jira のバックログ、Linear、そしてすべてのモバイル表示。作るのに コストがかからず、ブラウザ自身の挙動(Find、Back でのスクロール復元、 スマホのアドレスバー自動格納)がそのまま効きます。
高さ固定を選ぶのは、次の 4 つがすべて成り立つときだけです。
- **画面がアプリのフレームであること。**ページ自体がスクロールしては
いけません。さもないとスクロールバーが 2 本になり、読んでいる最中に
グリッドが動きます。
hc-shell+hc-fillはそのためにあります。 - ブレークポイント以下の逃げ道があること。
hc-shellは60rem未満で普通のスクロールページになります。スマホで固定フレームにすると データ領域が数行しか残りません。 - **印刷でスクロールポートの上限を外すこと。**紙にスクロールはあり ません。上限が隠していた分は、何の断りもなく消えます。オプトインの 印刷シートがこれを 行うので、読み込んでください。
- **行がページングされていること(無限ではない)。**無限リストの上に 固定ビューポートを置くと、位置について正直でいるために仮想化が要り ます。ページと件数なら要りません。
1 つでも成り立たないなら、ページをスクロールさせ、
--hc-datagrid-max-height は既定の 70vh のままにしてください。
固定ヘッダーだけでも利点の大半は手に入ります。
レイアウトの決まりごと
Section titled “レイアウトの決まりごと”<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 は自分の担当分を既に果たしています。__main は
overflow: auto かつ min-block-size: 0 なので、フレームがビュー
ポートを押し広げることはありません。
シェルのブレークポイント以下では
Section titled “シェルのブレークポイント以下では”60rem 未満では hc-shell は意図的に普通のスクロールするページに
変わります(display: block、overflow: visible)。高さ固定のアプリ
フレームはスマートフォンでは正しい形ではないからです。するとグリッド
の親に確定した高さがなくなるため 100% は何も制限せず、全行が実寸で
描画されてしまいます。ビューポート基準の上限に戻してください。
@media (width <= 60rem) { .page > .hc-datagrid { --hc-datagrid-max-height: 70vh; }}ページの骨格
Section titled “ページの骨格”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&f-carrier=road&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 個になっても壊れ ない形はメニューです。
パネルに残すのは条件を組み立てる終端の操作 — Update と
Save 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 → ユーザー設定 → アプリ既定保存ビューは列構成を固定してもよい(「出荷チェック」は普通、条件 とその仕事のための列を意味します)。固定した場合は、適用したときに 列が目に見えて変わり、戻る道も示します。
条件バーは画面の読み出し
Section titled “条件バーは画面の読み出し”タイトルの下の帯は装飾ではありません。いま何で絞り込まれているかで
あり、各チップがその条件を変える手段です。1 つは相対条件で、
start of this week (2026-08-10) のように保存された式と今日の解決値の
両方を表示します。条件が推測にならず、それを使った保存ビューが来週も
正しいままである理由です。
テンプレートが示す帰結が 3 つあります。
- **エクスポートがクエリを持ち歩きます。**ボタンは Export 5,000 rows と件数を述べ、href には同じ条件と列が載ります。ダウンロードは画面の 行ではなくこの問いを意味します。
- **ソートは条件と一緒に運ばれます。**ソートパネルがセット全体を
1 つの
sort=-ship,orderパラメータに直列化するので、Apply でも 維持され、保存ビューにも含まれます。 - **調整したビューはそう言います。**ビューメニューのラベルが Modified バッジを持ちます。ビューを適用してから条件を 1 つ変える のは保存ビューで最もよく行われる操作であり、バッジが正直でいられるの は比較の起きる場所 — 比較対象の名前の隣 — だけだからです。
パネルの版組
Section titled “パネルの版組”検索パネルは入力されるより読まれる回数の方がずっと多いので、走査 しやすさを基準に組みます。
- **ラベルはコントロールの上に。**走査も入力も速く、ラベル長にも強い です(“Buyer” と 発注者コード の差は、固定幅のラベル列では吸収でき ません)。
- フィールドは行の上端に揃えます(
hc-grid+data-align="start")。3 行のテキストエリアが隣を縦長の空箱に引き 伸ばすのを止めます。 - 複数行のものは 1 行を占有します(
data-span="full")。hc-gridはauto-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))。
アイコン
を参照してください。
クロームは O(1)
Section titled “クロームは O(1)”レイアウトの決まりごとには系があります。一括操作が 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-filterbar | datagrid-filter — 条件 1 つにつきチップ 1 つ。各チップが自分のエディタを開き、削除リンクは 1 パラメータだけ落とす |
| ツールバー | hc-toolbar + installToolbar() | —(Tab ストップ 1 つ、中は矢印キー) |
| ソート | ツールバーのコントロール + installSortList() | datagrid-sort — トリガーが集合全体を述べ、パネルが並べ替える(sort=-ship,order) |
| 選択時のアクション | installDatagridActions() | datagrid-bulk-actions、大量失敗時は datagrid-bulk-errors |
| ページャ | hc-pagination | datagrid-pager |
| グリッド | hc-datagrid | f-<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-group | datagrid-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-columns — cols= の繰り返し(ドラッグした順序のまま)。幅は 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"を付けると画面全体が 締まります。タブレット利用があるならタッチターゲットを確認して ください(テーマとランタイム軸)。