Repository navigation
v0.3.0
The data-grid release: the operations a business grid is actually used
for, PRs #487–#577. Eight new recipes (44 total) — datagrid-sort,
datagrid-filter, datagrid-prefs, datagrid-tree, datagrid-edit-errors,
datagrid-edit-conflict, datagrid-bulk-errors, row-detail — a fourth
page template (Data grid page, 4 total), the hc-filterbar component
(67 stylesheets), four new behaviors (installRangeValue(),
installMultiValue(), installRowLink(), installSortList() — 57 total),
the .hc-fill layout utility, row ordinals, and collapsible splitter
rails.
Minor rather than patch because of one behaviour-default change: bare
<a> now takes the theme's link colour (see Changed). Everything else
is strictly additive. The CLI ships 0.4.2 to re-bundle the eight new
recipes; @hypermedia-components/editor-kit is unchanged and stays at
0.2.0.
The link work reached the email render target too, where it turned up
a defect of its own: the dark flavor had been leaving links and tables on
their light colours — 2.77:1 and 1.21:1 against the dark container — because
a fragment can only be re-coloured by the dark media query if it carries an
hc-em-* class, and neither had one (see Fixed).
Added
-
tokens / base: document-level link colours —
--hc-color-link,--hc-color-link-hoverand--hc-color-link-visited,
generated per theme like every other token, plus bare-anchor rules in
@layer hc.base.hc.base.cssalready owned the document's background
and text but stopped short of<a>, so every anchor outside a component
fell to the UA's-webkit-linkblue and:visitedpurple — two colours
that follow neitherdata-theme,data-colornordata-neutral, and on
a dark surface the visited purple is close to illegible. This is what an
app hits wherever it renders prose rather than components: a description
field, a rendered markdown cell, an error page body.The
:visitedhalf is the part a consumer cannot write. Engines
refuse to resolvevar()in a visited-dependent declaration on purpose —
resolving it would let a page read the history bit back out through the
cascade — so the colour has to be a literal, which means it cannot be a
token. The build bakes one literal per theme instead, straight off the
same declaration it emits--hc-color-link-visitedfrom, so the rule and
the token cannot drift.Links are also the one accent value that is theme-dependent. Every other
accent token holds one value in both themes, because ramp step600is
white-text-safe on any hue; a link is text on the page surface instead,
and no single rung clears 4.5:1 against both backgrounds (blue.500is
3.61:1 on light,blue.600is 3.33:1 on dark). Light reads
600/700/800, dark reads400/300/200, and each non-default
accent gained acolor.<name>.dark.tokens.jsonemitted under the
compound[data-theme="dark"][data-color="<name>"]selector — the same
two-form selector the neutral ramps already use. All fifteen colours are
pinned at AA against--hc-color-bgand--hc-color-surfaceby a spec, so
a future re-ladder cannot quietly drop one below 4.5:1;
--hc-color-muted-bgsits outside that guarantee on purpose, being a
component tint whose foreground the component owns — andhc-chatnow
does own it: an assistant bubble is painted withmuted-bgand is prose,
so a link genuinely lands there, and the document's resting step would
score 4.40:1 (light) / 3.85:1 (dark) on it. A bubble re-pins its links one
rung further along the same ramp. It carries no:visitedrule, which is
not an oversight: a layer beats specificity andhc.componentssits after
hc.base, so the resting rule covers the visited state too. Visited is
unified with unvisited inside a bubble — partly the console-not-a-browser
argument, partly arithmetic, since the bubble's surface leaves only two
usable rungs in dark (the third,accent.100, scores 1.08:1 against the
bubble's own text and would read as body copy). The theme builder learned
the same trio, so a custom theme re-themes its links too.
(#569) -
docs: the template says when a fixed-height grid is the right
shape — and when letting the page scroll is. It is the operational
pattern (Fiori list reports, Salesforce list views, ServiceNow, AG
Grid, Ant Design'sscroll.y) and it needs four things to be true:
the screen is an app frame, there is a fallback under the breakpoint,
print un-caps the scrollport, and the rows are paged rather than
infinite. If any is false, page scroll plus a sticky header buys most
of the benefit for none of the cost. -
docs: the data-grid page template works now. Its rows,
conditions bar, pager, failure summary and docked panel are answered
by the docs demo API, so the contracts can be watched meeting each
other instead of being described one page at a time: filter and
remove a chip, sort (paging stays stable because ties break on the
primary key), open a record from its identity cell — the peek carries
the exit at the start andn / 24 ‹ ›at the end, and Next crosses
the page boundary because the server re-runs the query — save and
see the row behind update out of band, then tick rows and press
Approve to watch some fail: rows marked, one line of chrome with the
moves and Show only failed, and the breakdown waiting in the docked
panel's rail. The full-size preview is the same screen with the
viewport to itself. -
docs: the data-grid page template gets a full-size preview at
its own URL. The template is about a screen that takes the whole
viewport — chrome fixed, the grid taking the rest — and showing it
inside a documentation column is a picture of the idea rather than
the idea; at 36rem tall next to a 9rem sidebar it was simply too
small to read. The embedded demo is taller now, and Open the
full-size preview leads to a plain, chrome-free route where the
template owns the screen. One markup source serves both: the demo
component takes astandaloneprop and swaps its wrapper. -
layout:
.hc-fill— take the remaining space of a flex
column and let the children scroll. The composition every full-height
app screen needs, previously written as a structural rule in the
data-grid template (.page > form > .hc-datagrid), which was wrong
twice: a page may hold several grids — a detail screen stacks a
header grid, a lines grid and a history grid, and only one of them
should take the remaining height — and a descendant rule stops
matching the day someone wraps the grid in a<div>, silently,
with the symptom (the page scrolls instead of the grid) showing up
nowhere near the change. It carries both minimums, one per axis, and
goes on every element between the column and the filling region,
including a wrapping<form>. On anhc-datagridit also switches
--hc-datagrid-max-heightfrom the default70vh— right for a grid
inside a scrolling page — to100%, right for a grid that is
the page. -
components:
hc-splitterpanes can collapse to a rail —
data-collapsedgives a pane its content's size, hands the freed
space to its sibling (a fixed--hc-splitter-posbasis would leave a
hole) and hides the drag handle, since there is nothing to drag. The
state belongs to whoever owns the pane's content, so a server
re-rendering that region sets it on the fragment it already sends —
no client state, and the two panes cannot disagree. Collapse to a
rail, not to nothing: a panel that disappears when closed is a dead
end for the person who closed it. Thedatagrid-bulk-errorsdocked
panel adopts it: collapsed is the default, the rail carries the
count (Reasons (5)), and the response does not open it — the
summary already said what happened, and giving away the grid's width
is the reader's decision. A screen whose job is triage may start
open; whether it is open is workspace state, remembered per user
rather than in the URL. -
docs: the
datagrid-bulk-errorsdemo now shows the docked
panel the contract describes, instead of only describing it. The
chrome keeps one line — count, prev / next, Show only failed — and
the grouped breakdown rides to anhc-splitterpanel beside the
grid, resizable by pointer or keyboard, collapsing when there is
nothing to report. The panel is a server-owned region: hiding it
is a response (GET /report?close=1), not client state, so the two
surfaces cannot disagree. -
recipes: the bulk-error summary is also the navigator.
Twelve failures scattered through five thousand rows is a queue, so
the O(1) line carriesPrevious · Error 3 of 12 — row 137 · Nextas
real#row-<id>fragment links with a server-rendered counter:
Back works, the keyboard works,installDatagrid()lands the active
cell on the row a fragment names, and there is no client state to
drift from the panel. Rows are named by id and the ordinal is
shown beside it, because ordinals move when the sort or the
conditions change and ids do not. A Go to row control
(?goto=137) covers the number somebody read out loud — the server
resolves the ordinal to the page that contains it, since only it
knows where row 137 currently is. -
recipes:
row-detailgains the walks, with a live demo. Two
sequences, one shape: the result set (seq=list— the server
resolves neighbours by re-running the list query, so Next crosses a
page boundary without the client knowing pages exist) and the
selection — tick rows, Open selected (N) posts the sameids
every other bulk action sends, and the answer is a303to the
first record of an ordered snapshot. A record that vanished
mid-walk is a tombstone step with Next still working, because
aborting at the first gap makes the feature untrustworthy exactly
when data is moving; an unreadable or expired snapshot fails
closed (410+ a way back), never a silent fallback to walking
everything. The data-grid page template adopts the identity-cell link
and Open selected. -
recipes:
row-detail+installRowLink()/
data-hc-row-link— the most-used interaction on a business list,
and the one every app reinvents: open this record, work on it, come
back, open the next one. The link is an ordinary<a href>in the
row's identity cell, so middle-click, ⌘-click, copy-address,
Back, the keyboard and the no-JS path all work without any of them
being re-implemented; the behavior adds only what an anchor cannot do
itself, Enter anywhere on the row. Editing wins where it applies
(the datagrid cancels the event before opening an editor), a control
that owns its Enter keeps it, and a modifier means the user asked for
something else. The row is deliberately not one big link — the
datagrid ships text selection, range selection and TSV copy, and a
stretched anchor eats all three. Coming back is the part everyone
drops: the list URL already carries the conditions, sort, columns and
page, so the detail's Back to list is that URL plus#row-<id>,
whichinstallDatagrid()lands the active cell on. After a save the
detail **303**s there instead, because a restored snapshot shows
the data as it was before the user's own edit. The contract also
covers the peek rendering (canonical href kept), and walking a
sequence — the result set (seq=list) or the selection (an
ordered snapshot token, a tombstone step for a record that vanished,
410and fail-closed on expiry). -
datagrid: row ordinals —
data-row-noon a row and
data-row-totalon the grid, from whichinstallDatagrid()derives
aria-rowindexandaria-rowcount. A business grid is discussed out
loud ("row 137 is the one that failed") and the record id is right
for the system but wrong for the sentence. The ARIA numbers count DOM
rows including header rows while a server counts matching records,
so the offset is derived here rather than asked of every server —
getting it wrong is an off-by-header nobody notices without a screen
reader. It also fixes a lie the kit has been telling: without these
attributes a paged grid announces "row 3 of 40" on page four. Two
rules keep the number honest — the ordinal is a locator, the id is
the identity (ordinals move when the sort or the conditions change,
so anything stored names the id and merely displays the ordinal), and
it counts the result set, not the page. An omitted
data-row-totalmeans unknown (aria-rowcount="-1", the honest
answer for an infinite grid mid-load), and a row the server did not
number is left unnumbered rather than given a position it does not
have. -
recipes:
saved-views— saving asks three things, not one.
A dialog that asks only for a name pushes the other two decisions onto
whoever notices later, so the save form now carries scope
(personal / shared — a department standard is the normal case in
business software, and silently forking a colleague's view is an
accident) and default (a screen that opens on the wrong question
wastes a step every day; the bare list URL then303s to it, so the
address bar still shows the real conditions). The server owns the
rules: at most one default — a screen that opens on two different
questions has none — andPUT /views/<name>corrects what a view
asks without ever re-homing it or moving the default, because scope
and default are not conditions. The strip labels shared and default
views, and every chip offers Copy link
(data-hc-copy-text), because a view is a URL: sharing one costs
nothing and needs no shared object at all. The data-grid page
template's Save as new… now opens the dialog it always implied. -
components: the filter panel's typography, as reusable API. A
panel is read far more often than it is filled in, sohc-gridgains
data-align="start"(items align to the top of their row instead
of stretching — a three-row textarea stops inflating its neighbour
into a tall empty box) anddata-span="full"(an item takes a
whole row;auto-fillmeans which items pair up changes with
width, so nothing may depend on a pairing that holds at one width).
hc-fieldgainsdata-applied, marking a set condition with a
dot — a scanning aid, since the announcement is the conditions bar
above the data — with--hc-field-applied-marker-color/-size.
hc-input-groupnow strips a nested<select>'s chrome, so value +
operator reads as one control with one ring, anddata-quiet
drops that select's voice (not its hit area or its keyboard) for the
operator nearly every row leaves at its default. The data-grid page
template adopts all four, settles on one vocabulary (Apply), and
makes Cancel a nativeformmethod="dialog"submit instead of inline
JS.fundamentals/iconsgains the policy the screen follows: icon +
label unless the meaning is universal, icon-only for close / overflow
/ pager chevrons, counts in the label rather than a badge, and a
gear is not a columns icon — it reads as screen settings and
collides with them. -
docs: the data-grid page template gets a columns entry point.
Thedatagrid-columns
recipe already existed and the wiring map already named it — what the
screen lacked was a way in, so the chooser was unreachable. A toolbar
control opens it, with the count in the label (Columns (7 of 12)),
because a grid missing the column you are looking for is
indistinguishable from a grid whose data is missing. The template also
now states the rule the recipe assumes: a column set is a
preference, not a condition — it follows the user between screens,
so resolution is URL → user preference → app default, which is what
keeps a shared link authoritative. -
recipes:
datagrid-sort— the sort set as a control, plus
installSortList()/data-hc-sort-list. Header clicks are the fast
path and stay; what they cannot do is answer what the current sort
set is. Shift-click for multi-sort is undiscoverable, with thirty
columns the sorted one is usually scrolled out of view, a key on a
hidden column has no header at all, and re-ordering keys means
re-clicking headers in the right sequence. So sort gets what the
conditions got: one surface that is both the read-out (the trigger's
label is the applied set, server-rendered) and the editor (an
ordered list reordered byinstallSortable(), pointer and
keyboard, with per-key direction, remove, and an Add a column
list that includes columns the grid is not showing). The order of
the rows is the order of the keys — nothing duplicates that state.
installSortList()joins the rows into the unchanged
?sort=-ship,orderwire on theformdataevent, in place, so a
saved view's querystring comparison still works; without JavaScript
the per-keydir-<col>controls carry the keys, directions and
order, because form entries arrive in DOM order. Add and remove are
server round trips, because which columns are available is the
server's knowledge. -
behaviors:
installRangeValue()/data-hc-range— two
controls, one range param on the wire. A date filter is a period,
and a period is one condition: one chip in the applied-conditions bar,
one thing to remove, one value a saved view stores. So the wire
carries?f-ship=2026-07-01..2026-07-31, notf-ship-from+
f-ship-to— the two-param shape cannot let a preset set both ends
from one control without a hidden input, and hidden controls keep
submitting. Each end resolves on its own, so relative
(@month-start-1m..@month-end-1m), mixed
(@month-start-1m..2026-07-15) and open-ended (@month-start..)
ranges all work without a special case. The pair of date inputs keeps
real names, so the no-JS path submits a usable request and servers
accept both shapes; the behavior joins them on theformdataevent —
the hook htmx and a native submit both fire — so editing either end
costs no round trip.from > tois refused, never swapped: a
native validity message blocks the submit (and the demo API answers
400, because anyone can type a URL). Thedatagrid-filterdemo's
due-date condition is now a range with period presets, an absolute
branch, and the offset composer. -
docs: saved views move out of the filter dialog and onto the
screen. A list screen is asked four questions — what am I looking
at, narrowed how, in what order, showing which columns — and each
reads best with exactly one home; the first is answered beside the
title by anhc-menuwhose label is the applied view's name.
Recall was costing four interactions for the screen's most frequent
act, and a view is a named URL, which makes recall navigation
rather than filter editing. Items arerole="menuitemradio"(exactly
one view is applied, with Show everything as the none-of-them
option) and still real<a href>s, so views stay bookmarkable,
middle-clickable and no-JS. The panel keeps only the terminal actions
of composing a condition set, Update and Save as new…, which
also leaves one undo instead of two. Thesaved-viewscontract and
recipe page document the menu shape beside the chips strip, and the
data-grid page template shows it in place. -
recipes:
datagrid-filterdocuments (and demonstrates) how a
relative date gets entered. The expressions shipped as a wire
format with no affordance behind them — and nobody types
@today-7d. The control is a server-rendered list of presets whose
option values are the expressions, with the applied one rendered
selectedso a saved view reopens showing "This week" rather than a
raw expression; the server owns the list because it knows which
presets suit the column. Choosing Custom date… re-renders the
field as a date input rather than revealing a hidden one: hidden
controls keep submitting, so a hidden date input beside a visible
preset select would send the param twice and leave the server
guessing. One name, one control, always. Arbitrary offsets ("45 days
ago") come from a composer — a number and a unit, deliberately not
named after the condition, so nothing claims it until something is
chosen — which the server composes into the expression and returns as
a labelled selected option. A relative expression is never put in a
date input: the browser shows an empty field there and the condition
is lost on the next submit. -
docs: the
data-grid-pagetemplate adopts the filter-UX work
(plans/hc-filter-ux-plan-en.mditem G). Its illustrativehc-chip
strip becomes a realhc-filterbarwhose chips open their own
editors and whose remove links drop one param each; one condition is
relative, showing the stored expression and what it resolved to
(start of this week (2026-08-10)); the filter panel takes a pasted
list throughdata-hc-multi="lines", carries sort in a hidden
data-hc-datagrid-sortfield so a saved view captures it, and shows
the Modified state with Update / Save as new / Reset. Export
became a link carrying the current query and row count rather than a
bare button. The wiring map gains a row per new contract. -
recipes:
datagrid-filterdocuments that export inherits the
conditions (plans/hc-filter-ux-plan-en.mditem F). In a business
screen "download" means this question, these columns, every page —
not the forty rows on screen — so the export link is the same query
in another representation, with its href rendered by the server
(only it knows the canonical form of the conditions, and a relative
expression must travel as the expression so the export means what the
screen means). Columns come along, resolved the way the grid resolved
them; the label carries the row count, because an export is a
commitment and the number is what separates "this page" from
"everything"; and the page number is the one param dropped. Past the
point where a request would time out, answer202with a job pointer
— a silently truncated export is a wrong answer that looks like a
right one. -
recipes:
datagrid-bulk-errorscan act on everything that
matches, not only on ticked ids (plans/hc-filter-ux-plan-en.md
item E). Ticking rows stops working before the data does: when 4,873
rows match, the wanted operation is "archive all of them", and 4,873
ids fit in neither a querystring nor a form post. A request may now
carryscope=matchingplus the conditions themselves — the same
f-*params the list URL uses — and the two shapes are mutually
exclusive (400if both). The count is part of the confirmation: the
button says the number, the server re-counts, and acount-token
pins the count the user was shown. If it has moved — someone else's
edit, a relative date rolling over at midnight — the answer is409
with the old and new counts and a fresh token, never a silent run
against a different set. Without the token, "archive all 4,873"
executes against however many rows exist at execution time, which is
a different operation from the one the user agreed to. -
recipes:
saved-viewsgains a modified state, update in
place, and a persistence model (plans/hc-filter-ux-plan-en.md
item D). Applying a view and changing one condition is the commonest
thing users do with saved views and was the least served: nothing
said whether what you were looking at was still the view, so the user
either lost the tweak or trusted a saved version that was not on
screen. The apply link now names its view (&from-view=<name>), the
server compares normalized conditions (sorted params, so the same
question compares equal whether it arrived from the form or from a
link), and the chip rendersdata-modifiedwith Update and
Reset.PUT /views/<name>updates in place, so correcting a view
keeps its name and every link already shared — previously the only
route was delete-and-recreate. The contract also writes down what a
view captures (filters, sort, pinned columns; never the page
number), that columns resolve URL → user preference → app
default, that scope may be shared rather than personal (so editing
a colleague's view is a visible act, not a side effect), that a
default view redirects with303so the address bar shows the real
conditions, and that applying re-authorises and fails closed —
quietly dropping a condition the user may no longer run would widen
the result set. -
recipes: filter conditions accept relative date expressions
(plans/hc-filter-ux-plan-en.mditem C). A saved view is a stored
querystring, so an absolute date makes the view wrong tomorrow —
"shipping this week" saved on Monday means last week by the following
Monday, and time is what a large share of real saved views are about.
Condition values may now be@today,@week-start/@week-end,
@month-*,@quarter-*,@year-*, or an offset from any anchor
(@today-7d,@month-start-1m), and the expression is what gets
stored. The server resolves — never the client, whose clock and
timezone would leak into the answer — against one instant per
request, so a request near midnight cannot straddle two days.
Absolute values stay ISO on the wire (2026-08-01, never
01/08/2026, which means different days to different colleagues);
localize on the way out. The applied-conditions bar shows both
forms —start of this week (2026-08-10)— because the wording alone
hides which rows are in, and the date alone hides that it will move.
An expression the server does not understand answers400, never
the unfiltered list. -
behaviors:
installMultiValue()— one control, many values on
the wire (plans/hc-filter-ux-plan-en.mditem B). The filter wire
already took repeatedf-<col>params; nothing let a user enter
them.data-hc-multi="lines"on a<textarea>(orcommas) makes
each line its own entry, so a column of order numbers pasted out of a
spreadsheet becomes
f-buyer=A&f-buyer=B&f-buyer=C. The split happens on theformdata
event — the hookinstallFormat()already uses, which htmx's
new FormData(form)and a native submit both fire, so one listener
covers both transports and nothing wraps the network. Entries are
rebuilt in place rather than appended, so the same conditions
always serialize in the same order (a saved view compares
querystrings). Values are trimmed and de-duplicated, and a control
emptied of everything contributes no entry at all. Servers should
still accept the raw newline-joined value, which is what the no-JS
path sends.datagrid-filter's contract gains the section, including
what to do when the list outgrows a URL: a stored condition set
addressed by id — which must answer404rather than fall back to
"no filter", since a silently dropped condition shows more data than
was asked for. -
recipes:
datagrid-filtergains the applied-conditions bar
(plans/hc-filter-ux-plan-en.mditem A). Column popovers are how a
condition is created; they are a poor way to find one again — in a
wide grid the column may be scrolled out of view, and plenty of
conditions do not belong to a column at all. The response now also
renders anhc-filterbar: one item per applied condition, each chip
opening an editor for only that condition, each remove control a
real link to the current URL minus that one param (so it works
without JavaScript, is shareable, and Back puts the condition back).
Values arrive summarised —2 values, not one chip per value —
because only the server knows the label, the operator and the count.
The contract also gains the empty-result rule: answer a
filtered-to-nothing list with a link that drops the newest
condition, since that is what the user just did.checks.json
enforces that remove controls are links and name their condition. -
datagrid: sort now travels with the form, so it survives an
Apply and is captured by a saved view
(plans/hc-filter-ux-plan-en.mdPR-4).installDatagrid()writes the
whole ordered sort set (name,-price— leading-for descending)
into everyinput[data-hc-datagrid-sort]in the grid's closest
<form>before dispatchinghc:datagridsort, exactly as it
already does for column widths, so an event-triggered request
serializes the fresh value. Sort used to arrive from the grid's own
data-hx-valswiring, outside the filter form — which meant filtering
could silently reset the order, andsaved-views(which stores the
form's fields) saved a view that had forgotten how the list was
ordered. The docs also stop contradicting themselves: the page
documented?sort=name,-priceand then wired a single-column
{ sort, dir }pair that cannot round-trip multi-column sorting.
Additive — a grid without the input behaves exactly as before. -
filterbar: new component — the applied-conditions bar above a
list (plans/hc-filter-ux-plan-en.mdPR-1). Applied filters had no
component:hc-chipis documented as presentational andhc-chips
wraps, while a condition bar is one line that scrolls and whose
chips are controls. Each.hc-filterbar__chipis a
<button popovertarget>that opens the editor for its own condition
— reachable even when that column is scrolled out of the grid, and
available for conditions that are not columns at all — and
.hc-filterbar__removeis a real<a href>to the same URL minus
that condition, so dropping a filter works without JavaScript, is
shareable, and Back puts it back. Chips do not shrink (the bar
scrolls instead) and.hc-filterbar__clearis pinned to the trailing
edge, because clearing everything must not require first scrolling to
the end of what you want to clear. The server owns the chip's text —
label, operator and value — so a multi-value condition arrives
summarised ("3 values") rather than as three chips or one 200-character
one, and a long single value truncates at--hc-filterbar-value-max
with the full text in the editor. Newfilterbar.*tokens. -
docs: the datagrid page documents the scroll area. The grid has
one scroll container holding header, body and footer, and the header
holds still because its cells are sticky — so the vertical scrollbar
necessarily runs alongside the header, not only beside the data.
Records what a data-only scrollbar would cost (two tables, scripted
column-width and horizontal-scroll sync, and explicit
aria-colindex/aria-rowindexin place of the single accessible
tablerole="grid"derives for free), and the cheap alternative when
the goal is a quieter bar rather than a shorter one
(scrollbar-width: thin, deliberately not a default). Also notes that
sticky lives on the header cells, not the row — measuring the row
reports a bug that is not there. -
docs: a fourth page template — Data grid page
(templates/data-grid-page). The business list screen: anhc-shell
frame whose grid takes the remaining height so only the grid
scrolls (both axes) under sticky multi-level headers and frozen
columns, with a toolbar whose trailing group is pushed by
data-hc-spacer, and filter input in a dialog. Documents the trap
that makes or breaks the layout — every element between the page
column and the grid needsflex: 1; min-block-size: 0, and the grid
needs--hc-datagrid-max-height: 100%; miss one and the page
scrolls instead, taking the toolbar with it. Includes a wiring map
from each region to the recipe contract its endpoint implements. -
recipes:
datagrid-edit-errorsgains a fourth outcome —
confirmable warnings
(plans/hc-datagrid-state-clarity-plan-en.mdPR-4). The contract had
accepted /422rejected /409conflict, and business apps need
the case where the value is acceptable but unusual and only the
server knows it needs asking about: a ship date in the future, a
discount above policy.422would tell the user to change something
that needs no changing, and a client-side confirm cannot express a
rule discovered on the way in — so the branch is200(nothing
failed; the server is continuing the conversation, and no
htmx:beforeSwapallowance is needed) with the record re-rendered in
a confirm-pending state: the proposed value in the cell marked
data-attention="warning", and adata-tone="warning"message row
withrole="alert"offering Confirm and Cancel. Cancel is a plain
GETof the record — nothing was written. The confirmation token is
bound to (row, column, value[, version]), so a confirmation
obtained for one value cannot commit another or replay past the
409guard; the buttons carry staticdata-hx-vals(notjs:), so
the value is pinned at render time and stays CSP-safe. Core adds the
cell-level warning marking the state uses. -
datagrid: opt-in zebra striping
(plans/hc-datagrid-state-clarity-plan-en.mdPR-3).data-hc-zebra
on the grid makesinstallDatagrid()assigndata-altto alternate
rows on every rebuild, because:nth-child()cannot express it: it
counts rows hidden by a collapsed group (so the stripes shuffle the
moment a group closes) and it cannot alternate per record — a
.hc-datagrid__recordspanning three physical rows must stripe as one
block. Both are thingsrebuild()already knows. The stripe is the
bottom rung of the tint ladder, so hover, selection and the attention
bar stay visible over it, and frozen columns keep their stripe. New
--hc-datagrid-row-alt-bgtoken. Without the opt-in the behavior
leavesdata-altalone, so a server that renders it directly needs no
JavaScript. -
datagrid: editability states are now announced and afforded
(plans/hc-datagrid-editability-plan-en.md§1.1, §1.2).
installDatagrid()derivesaria-requiredfrom the column
editor template'srequiredandaria-readonlyfrom the absence
ofdata-editable— per cell, so row-state-dependent editability
(unshipped editable, shipped locked) works without a client-side
rule; a server-rendered value always wins, and a wholly read-only
grid says so once on the table (which carriesrole="grid") instead
of on every cell. Editable
cells gain a hover/focus affordance by default,*marks anything
aria-required, and the opt-indata-hc-editable-hint="editable" | "readonly"marks whichever of the two is the exception in that grid
(forced-colors fallback included). -
recipes:
datagrid-bulk-errors— bulk-action failures at scale
(plans/hc-datagrid-bulk-errors-plan-en.md). Makes the execution
semantics an explicit contract choice: best-effort (200, rows
reflect what happened, failures marked with their reason, a
"filter to the failed rows" retry link) vs atomic (409/422,
rows unchanged, selection preserved, copy framed as refusal
rather than partial completion), with a pre-flight step that
reports executability before anything runs and offers to exclude the
blockers. Failures are reported in onearia-liveregion grouped
by reason with a hard cap plus a full-list escape hatch, and every
named row links back into the grid (#row-<id>, or
?focus=<id>#row-<id>for another page). Machine-checked contract;
live docs demo (en/ja). -
recipes:
datagrid-edit-conflict— the 409 wire for datagrid
inline editing (plans/hc-datagrid-edit-feedback-plan-en.md§1.3).
Optimistic locking per row: the record<tbody>carries
data-versionand the PATCH includes it; a stale version answers
409with the record re-rendered as a conflict presentation — the
server's current values in the cells (data-tone="error"), the
fresh version, and arole="alert"conflict row naming both values
with Overwrite (static-vals re-submit against the fresh version)
and Discard (GETof the row) actions. The row is the merge UI;
overwrite is last-writer-wins by explicit consent. Machine-checked
contract; live docs demo (en/ja). -
recipes:
datagrid-edit-errors— the 422 wire for datagrid
inline editing (plans/hc-datagrid-edit-feedback-plan-en.md§1.2).
Each row is its own record<tbody>carrying the persistence wiring
(hc:datagridedit→ PATCH →outerHTML);200answers
the record with the row alone (server formatting confirms the
optimistic commit and clearsdata-pending),422answers the
record with the server's value restored, the cell marked
data-invalid+ aria wiring, and the__error-rownaming the
rejected input — one atomic swap unit, no stale error rows.
Machine-checked contract; live docs demo (en/ja).
Changed
-
base: bare
<a>now takes the theme's link colour instead of the
UA's-webkit-linkblue and:visitedpurple. This is the one
behaviour-default change in the release, and the reason it is a minor
rather than a patch: an app that relied on the UA defaults for anchors
outside a component will see them re-coloured on upgrade. The rules land
in@layer hc.base, so any unlayered app rule still wins, and every
component anchor (hc-button,hc-breadcrumb__link,hc-toc__link,
hc-pagination__item) is unaffected —hc.componentsis the later layer.
To keep the old look, set the tokens to the UA colours or overridea
outside thehclayers. -
docs / base: two stale claims went with it —
hc.base.css's
::selectioncomment still described a "12 % (18 % for amber)" tint, but
amber stopped being an accent axis in 0.2.0 and the ladder work removed
its soft-tint special case, so all five axes have been a flat 12 % for a
while; and the theming guide told you to mirrorcolor.indigo.tokens.json,
a file the same release deleted when the accents became the five-hue
pentagon. -
docs: in the working template, a row click is now a real
navigation. The record has its own prerendered URL
(/templates/data-grid-page-record/<id>/), built from the same data
the demo API serves, so a click behaves the way business software
does — Gmail replaces the screen, Fiori splits it, Salesforce gives
the record a page. Back to list carries the list query and the
row anchor, and the preview seeds its first request from that query,
so the list really does come back as it was with the row under the
cursor. The peek dialog stays as the enhancement layered on the same
href, with a link to the page inside it. The previous excuse — that a
documentation page is a single route — was only true until a second
route was written. -
docs:
row-detailranks the three renderings the way business
software actually does, and says why. Opening a record replaces the
screen in Gmail, splits it into columns in SAP Fiori, and is a
page in Salesforce and ServiceNow; modals in those products are
for short, self-contained tasks — create one thing, confirm, edit a
field — not for the record, because a record is where the work
happens and work needs room, a URL and its own error surfaces. The
failure mode is named too: a modal with no URL, where Back closes
something the user never opened, the link they send a colleague is
the wrong screen, and a refresh loses their place. The template says
plainly that it peeks because a documentation page is a single route
— the row'shrefbeside it is the real page. -
recipes:
row-detailstates where a detail screen's navigation
goes, since the list template's "navigation under the data" rule
reads as "put a pager at the bottom" if left unqualified. Prev / next
belong in the record's header: the decision to move on is usually
made before reading to the bottom, a bottom control on a scrolling
body either scrolls away or buys a second fixed strip, and the303
after a save lands the user at the top anyway. A long detail may
repeat them below as a secondary copy — same links, no state. And a
grid inside a detail pages itself, directly under itself: a
page-level pager on a screen with three grids cannot say which grid
it pages. The bottom of a detail carries its actions, not
navigation. Within the header the arrangement is the one every mail
client already taught users — the exit at the start, the walk
(1 / 15,129 ‹ ›) at the end — the same rule the list's navigation
strip follows. -
docs: in the data-grid template's navigation strip, where you
are stays at the start and where you go moves to the end. Two
reasons about hands rather than taste: after scrolling the grid the
pointer is already at the trailing edge, where the scrollbar lives,
and Next is pressed far more often than anything else on the
strip. The count keeps the start because it is a read-out and the
frozen identity column it refers to is on that side. Logical
properties, so RTL swaps both without a second rule. -
docs: the data-grid page template groups its controls by what
they change, because one strip holding four kinds of control reads
as clutter however tidy each one is. Filters, Sort and Columns now
sit together beside the title — they answer the same question, what
am I looking at, and splitting them made the screen look busier than
it was. The toolbar keeps only actions on the data (Refresh,
Import, Export). Selection-scoped actions moved to their own bar,
revealed byinstallDatagridActions()when rows are ticked: Approve
and Reject apply for the minutes a selection exists and were being
read all day for the rest of the time — and a bar that appears is a
better cue than a button that greys out, because a disabled button
explains nothing. Navigation (the pager, Go to row) moved under
the grid, where the movement happens. -
recipes: the
datagrid-bulk-actionscontract's "the selection
clears by construction" is now scoped to the branch where the action
actually ran, with a carve-out for refusals — an all-or-nothing
refusal must re-render its rows with the checkboxeschecked, or a
hand-picked selection is destroyed when nothing happened. -
datagrid: fragment navigation and the error-tooltip carve-out
(plans/hc-datagrid-bulk-errors-plan-en.md§1.4, §1.5). A link to a
row (#row-101— a bulk-error report entry or a deep link) now moves
the active cell to that row's first cell and focuses it (on load
and onhashchange; unknown or unusable hashes are ignored), so
keyboard and screen-reader users arrive where the eye does. The
landing row is emphasised with:targetand carries
scroll-margin-block-startderived from the measured header heights
so it never lands under the sticky header (forced-colors fallback
included). A cell carrying its own message — server-rendered
data-invalid, oraria-describedbypointing at anhc-tooltip—
now suppresses the built-in overflow tooltip, so one hover never
carries two meanings.
Fixed
-
email: the dark flavor left links and tables on their light
colours. The layout's@media (prefers-color-scheme: dark)block flips
the background, the container, headings, body copy, muted copy and the
separator — but a fragment can only be reached by that block if it carries
anhc-em-*class, andlinkandtablehad none. Email bakes every
colour inline, so what survived the flip was a link at 2.77:1 against
the dark container and table copy at 1.21:1, which is dark text on a
dark surface. Both now carry classes (hc-em-link,hc-em-table/
hc-em-th/hc-em-td) with matching dark rules, reaching 5.85:1 and
13.34:1. Alerts, badges and buttons are untouched: each brings its own
background and foreground, so it is legible either way.The link fragment also read
color-action-primary-bg, which is the colour
a button sits on with white text over it, not a colour text is painted
in — and it holds the same value in both flavors, so no dark rule could
have saved it. It readscolor-linknow (#569). -
theme builder: a custom theme built in accent mode emailed a stock
blue dark-mode link instead of its own accent.theme.darkis overlaid
after the custom accent, and it now carries link tokens, so it won the
resolution — a regression from adding them (#569). The builder's custom
accent gained the dark counterpart the stock accents ship as
color.<name>.dark.tokens.json, wired into all three outputs (the
paste-ready block, the full token CSS, and the email maps), so a teal theme
stays teal in dark. -
tests: the two specs that follow a
#rowfragment link read the
focus a task too early, and one of them failed roughly two full-suite
runs in three while passing every time in isolation. Following the link is
a same-document navigation: the browser blurs the anchor on the way
through — the active element becomes<body>— and queueshashchangeas
its own task, sofocusHashRow()has not run whenclick()resolves.
Measured, the active element is still<body>through the next microtask
and animation frame. Both specs now use retrying locator assertions
(toBeFocused(),toHaveCount()) instead of a one-shot
page.evaluate(() => document.activeElement). Nothing in the product
changed: the cell was always focused, just after the assertion looked. -
dialog:
prefers-reduced-motion: reducedid not actually stop
the dialog from animating in. The guard was written against the
bare.hc-dialog, but the enter transition is declared on
.hc-dialog[open]— one attribute more specific, so the guard was
outranked and never applied. A reader who asked for no motion still
got the 200ms fade-and-scale; only the exit was ever zeroed. The
guard now lists the[open]states (and their backdrops) so it
matches that specificity and wins on source order. This was also the
root of an intermittent CI failure: axe samples rendered pixels, and
mid-fade the primary button's blue composites toward the page behind
it and scores ~3.5:1 against white instead of the 5.31:1 it resolves
to at rest — Chromium and WebKit usually settled before the scan,
Firefox did not. Pinned by a spec that opens a dialog under reduced
motion and asserts it is fully opaque on the first visible frame. -
print: a fixed-height datagrid printed only the rows that
happened to be visible. The print sheet reset the wrapper, but the
cap lives on the scrollport (.hc-datagrid__scroll), and
max-height: noneon a parent does not reach it — nor does the
physical property override the logicalmax-block-sizethe component
sets. On paper there is no scrolling, so whatever the cap hid was
simply missing with nothing to say so. The scrollport now un-caps in
print, pinned by a spec that checks the computed style under both
media. -
docs: in the working template, clicking a row still opened the
modal — the record's name carried both anhrefand a
data-hx-get, and htmx takes the click, so the real navigation added
alongside it was reachable only by middle-click. The peek now has
its own control (a button in a trailing column) and the record's
name is a plain link, so a click navigates. The rule is in the
row-detailcontract and docs now, because the markup looks correct
either way: one anchor cannot be both — anhrefunder a
data-hx-getis a claim nobody can act on. -
docs: the working template opened records only as a modal,
which contradicted therow-detailcontract it is meant to
demonstrate: a record reachable only through a dialog cannot be
linked, bookmarked or opened in a second tab, and the peek was
missing the Open full page link the contract requires — a peek that
traps you is worse than no peek. The row'shrefis now the
record's own page, which the demo API answers as a real page
(Back carrying the list query and the row anchor); the dialog is the
enhancement layered on top withdata-hx-get, and it carries the way
out. The recipe now also states plainly when each of the three shapes
is right — page (default), peek (glance and go, short records), or a
docked pane (when the work is comparing record and list). -
docs: the template's full-size preview showed no data. The
page is a plain Astro route, not a Starlight one, so it never got the
DemoFramethat loads htmx for every other live demo — leaving every
data-hx-*attribute on the screen inert: the grid'sloadrequest
never fired, the rows stayed empty and the chrome kept its
placeholder text. It loads htmx now (with the same 401 / 409 / 422
swap allowance the recipe contracts document) and the two docs
stylesheets Starlight applies throughcustomCss, so the shell's own
chrome stops rendering raw. -
docs: the
datagrid-filterlive demo was missing two of the
regions its own responses fill, so both landed nowhere: the
applied-conditions bar and the due-date control — which is where
relative dates (@today-7d,@month-start-1m..@month-end-1m, the
preset list, the offset composer) are entered. An out-of-band swap
with no target does not fail loudly: htmx drops the fragment, the
page renders, and the feature is simply invisible. Both regions exist
now, and a test checks the demo pages against the regions their APIs
answer — from both ends, so the list cannot drift into fiction. -
docs: a bulk-error report could squeeze the grid to nothing
on the full-height list page. The chrome is fixed and the grid takes
what is left, while the report's height isO(number of failure reasons)— so an action that failed in fifteen ways hid exactly the
rows it was telling the user to go and fix. The template now states
the corollary of its own layout rule — the chrome is O(1);
anything that grows with the data lives in the scrolling area or an
overlay — and carries a one-line summary with Show only failed
(N), with the failing rows markeddata-attention="error". The
datagrid-bulk-errorscontract picks the surface by one question,
is there work in the grid?: best-effort → the summary plus the rows
(and the filter, which turns the grid into the report); the
grouped breakdown → a docked panel beside the grid, because a
side panel spends horizontal space, which this layout has; atomic →
a modal, where blocking is the message. The region is bounded as
a backstop (max-block-size: min(25vh, 12rem); overflow: auto), so
even a full report scrolls inside its own box and the data never gets
less room than the diagnostics. -
tests: the session-expiry dialog's axe scan emulates reduced
motion (the #342 pattern) — it could sample the dialog mid-transition
and report a colour-contrast violation that is gone once it settles. -
behaviors:
data-hc-close-popover-on-success/
data-hc-close-dialog-on-successgained a nearest-carrier opt-out
(="false"). A panel that edits itself — a sort control adding a key,
a column chooser — issues successful requests from inside the carrier,
and every one of them dismissed the panel the user was still working
in. The nearest carrier now wins, so an inner region can opt its own
round trips out while Apply still closes the panel. -
docs: two defects in the data-grid page template's filter panel.
Its Reset was<button type="reset">, which restores the values
the server rendered into the controls — after an apply plus a tweak,
the modified state: the one control promising to undo the tweak was
the one guaranteed not to. It is now a link to the view's own URL, as
thesaved-viewscontract now says explicitly. The Modified badge
also rendered unconditionally next to aviewselect showing "—";
it now lives on the view menu's label, where the comparison it reports
actually happens. The condition chips carried
popovertarget="…-filters"pointing at a<dialog>with nopopover
attribute, so clicking a chip did nothing at all — they open the panel
now. -
recipes: relative date expressions handle period offsets and
stop overflowing.@month-end-1m— "end of last month", the phrase a
business user actually reaches for — was rejected outright: offsets
were only parsed fromtodayand the-startanchors. Offsets now
work from any anchor, and on a period anchor they shift the period
and then take the boundary, so@month-end-1mis the end of the month
a month back rather than this month's end minus a month — the same
thing in August, not in March. Month and year arithmetic also
clamps instead of rolling over:@today-1mevaluated on 31 March
answered 3 March, so a filter asking for "the last month" quietly
covered a month it was never asked for. It now answers 28 February
(29 February in a leap year), and@today-1yon 29 February answers
28 February. Two presets are added for the common case. -
docs: the
data-grid-pagetemplate scrolled its chrome
horizontally. The layout rule documentedmin-block-size: 0— the
block-axis half of the trap — and missed its inline twin. A flex item
will not shrink below its content on either axis, and the datagrid's
table isinline-size: max-content, so the page column grew to the
table's width andhc-shell__mainbecame the horizontal scrollport:
scrolling right dragged the title and the toolbar along, which is
exactly what the grid's own scrollport exists to prevent (measured:
542 px of overflow onhc-shell__mainat a 1280 px viewport, and the
grid never scrolled horizontally at all). Both minimums are now
documented per axis, in the template and its demo. The template also
restores a70vhcap belowhc-shell's60rembreakpoint, where the
shell deliberately becomes an ordinary scrolling page and100%stops
capping anything — without it the grid rendered every row at full
height. A newdatagrid-app-pagebrowser fixture and spec pin the
composition on both axes; removing either minimum fails them. -
dialog: a dialog taller than the viewport now scrolls its
body, not its header and footer..hc-dialoghad no column
layout and no scrolling body, so an over-tall dialog scrolled as a
whole under the browser's height cap — the title left the top of the
screen and the footer, where the primary action lives, left the
bottom (measured: at a 460 px viewport the Apply button sat at
y = 604). An open dialog is now a flex column with anoverflow: autobody, and the column passes through a form wrapping
header/body/footer — the usual shape when the footer's button submits
the body's fields, as in a filter panel. The rule is scoped to
[open]: an unscopeddisplaywould beat the UA's
dialog:not([open]) { display: none }and reveal every closed dialog.
Markup is unchanged. -
recipes: the atomic branch of
datagrid-bulk-errorsnow marks
the blocked rows. The rule was "never mark in the atomic branch —
nothing changed, so marking would lie", which conflated two different
claims: a failure would indeed be a lie, butdata-attention="error"
says "this row cannot proceed", and that is a fact about the row,
equally true in the pre-flight, in the409refusal and after a
best-effort failure. It does not go stale when the selection changes
either ("already shipped" stays true whether or not the row is
ticked). Without it, the report's row links landed on a row that
looked like every other row. The pre-flight answers a report rather
than rows, so it carries the marks as<template>-wrapped
out-of-band row updates, renderedcheckedso the selection the user
is about to act on survives. Row statuses are still never changed
by the atomic branch. Documented alongside it:data-attentiontakes
its severity from what the row needs —errorwhen something must
change (missing required value, invalid input, wrong state, no
permission),warningwhen someone must decide (a future ship
date) — not from when it was discovered, or the same unchanged row
would readwarningbefore an action anderrorafter it. -
recipes: the bulk retry copy no longer says "The other 1 need a
change first" (missing singular). -
recipes: a partial bulk failure now leaves the retry set
selected (plans/hc-datagrid-state-clarity-plan-en.mdPR-2). The
datagrid-bulk-errorsbest-effort branch re-rendered every row
unchecked, so the actions bar (which hides at zero) disappeared the
moment anything failed and the user had to hand-pick the failures out
of a full grid to try again — the very rows the server had just
identified. Failures the server judges retryable (transient: lock
held, upstream timeout, rate limit) now come backchecked, so
pressing the same button retries exactly those; succeeded rows and
permanent failures (wrong state, not permitted, invalid data) come
back unchecked, because re-submitting either is pointless. The report
says which is which — a partially-checked grid reads as a bug
otherwise. Contract, recipe scaffolds, docs-site demo endpoint and
browser fixture all carry the rule. -
datagrid: states no longer erase each other
(plans/hc-datagrid-state-clarity-plan-en.mdPR-1). Every state
painted the same property — abackground-imagegradient on the cell
— so exactly one won, and which one was decided by an accidental mix
of specificity and source order: hover erased a failure tint,
selection erased a rejected cell's ring, and a row-leveldata-tone
erased the selection tint entirely, leaving no feedback while the
user selected failed rows to retry them. There is now one paint
fed by--hc-datagrid-cell-tint, and each state merely assigns it in
a documented priority ladder (data-tone→:hover→
data-pending→data-highlight→data-in-range→
aria-selected→:target→data-invalid), with every selector
specificity-normalised via:where()so source order alone decides.
Anything that must survive a tint moved to an attention channel
that never touches the background: the new
data-attention="error" | "warning"(row / record / head cell)
draws an inline-start edge bar composed from a shadow channel, so it
coexists with a freeze line; the rejected cell's ring and corner flag
now use--hc-datagrid-attention-error-bg(status.error.fg)
instead ofstatus.error.border, which was nearly invisible against
the error tint it sat on. A selected failed row now reads as both.
Failed rows indatagrid-bulk-errorsand the conflicted row in
datagrid-edit-conflictmoved fromdata-tone="error"to
data-attention="error"(data-tonekeeps its meaning: the
value is notable). Fragment navigation additionally accepts a
cell id (#cell-101-ship-date), so a failure report can land on
the offending column — which a wide grid otherwise hides — and
data-attentionon a header cell marks that column. -
datagrid: cell state markers no longer change the column width.
The table ismax-contentsized and cells do not wrap, so the
saving spinner (shipped as an inline-block pseudo-element) widened
its whole column — measured at 76 px → 121 px for one small inline
addition — and would have been clipped away entirely in a
data-resizedcolumn. The spinner, the new rejected-cell corner
marker (data-invalid) and the per-cell required*are now
absolutely positioned in the cell's padding gutter at zero layout
cost, and thedatagrid-bulk-errorsrecipe no longer puts an inline
"details" link inside a data cell (Back returns to the report, which
the row link already put in history). The rule is documented for
app-authored markers too. -
docs / recipes: English-facing surfaces are English again. The
datagrid-bulk-errorstheme shipped its demo API, recipe scaffolds,
server contract, English docs page and browser mocks with Japanese
message text, so the shared live demo and the canonical English
source format answered in Japanese for every reader. Also cleaned
two older leaks — theedit-conflictandautosaveexpanded
scaffolds glossed their buttons in Japanese. Japanese remains where
it belongs: the/ja/docs mirror, the i18n message catalogs and
their examples, and the Japanese-specific features (kana
normalization, postal addresses, non-ASCII header escaping tests). -
datagrid: a row replaced while its editor was open (an SSE
update, another user's change, a pager refresh) left the internal
editing state pointing at a detached node — and since the keyboard
handler returns early whenever an edit is in progress, the grid's
keyboard navigation stopped responding until an edit was started
and finished again; a later commit would also have written into a
node no longer in the document. The behavior now drops the editing
state when the edited cell leaves the grid, so navigation and
editing resume on the swapped-in rows. Row-state-dependent
editability makes this a routine path, not a corner case. -
docs / recipes: the
datagrid-infinitelive demo chain-loaded
all 15 rows before the reader could scroll.revealedmeasures the
window viewport, so on a tall screen every renewed sentinel was
already visible and each batch fired immediately — the demo showed a
full table instead of infinite scrolling. The demo is now the
container-scrolled variant (the grid keeps its scrollbar; sentinels
trigger onintersect once root:<scroll> threshold:0.5, with the
root threaded through the cursor URL so renewed sentinels keep it).
The recipe's page-scroll contract is unchanged and now carries a
container-scrolled carve-out documenting the trigger swap and
both failure modes (deadlock and chain-load);hc validateaccepts
either trigger. -
datagrid:
hc:datagrideditnow dispatches from the edited
cell (bubbling through row → record → grid) instead of the grid
element — per-record htmx wiring can finally hear only its own
edits; grid-, body- and document-level listeners are unaffected by
the bubble. Record-tbody swaps (the edit-errors contract's unit) now
trigger the structural rebuild too: the behavior additionally
observes the table's children, so swapped-in records get roles, the
navigation matrix, and editing wired without a full grid swap. -
datagrid: inline-edit lifecycle states
(plans/hc-datagrid-edit-feedback-plan-en.md§1.1). With
data-hc-datagrid-pendingon the grid wrapper, a changed commit
marks the edited celldata-pending+aria-busy(busy tint +
spinner) until the server's row re-render replaces it — opt-in
because it assumes the re-render contract.data-invalid
(server-rendered in a 422 re-render) paints the rejected cell with
an error ring + tone;.hc-datagrid__error-row/
.hc-datagrid__erroris the message slot directly under the row
(grid roles applied, out of keyboard navigation,role="alert"on
an inner element). Forced-colors fallbacks; reduced motion stops the
spinner. -
datagrid: auto-size and opt-in client page sort
(plans/hc-datagrid-enrichment-plan-en.md§1.11). Double-click a
column's resize grip (or press Enter while it has focus) to fit the
column to its widest rendered cell — the committed width flows
through the normalhc:datagridcolumnresizepipeline.
data-sortable="client"sorts the already-rendered page rows in
the DOM (numeric when both values parse,data-valuepreferred over
cell text, locale compare otherwise) — allowed by the v0.6 depth
plan for small fully-loaded tables; any htmx swap restores the
server's order, and baredata-sortablestays server-instructed. -
datagrid: column-preference persistence + the
datagrid-prefs
recipe (plans/hc-datagrid-enrichment-plan-en.md§1.10). Widths:
installDatagrid()mirrors every committed resize into any
input[data-hc-datagrid-width="<col>"](grid's closest form, else
document-wide) before dispatchinghc:datagridcolumnresize, so a
debounced event-triggered form autosaves the fresh value; the server
renders remembered widths back as inline widths +data-resized.
Order: the datagrid-columns chooser upgraded with
installSortable()— checkbox serialization follows DOM order, and
the datagrid-columns contract now honors the submittedcols=
sequence as the column order (absent/unknown params unchanged).
Machine-checked contract; live docs demo (en/ja). -
datagrid: tree rows + the
datagrid-treerecipe
(plans/hc-datagrid-enrichment-plan-en.md§1.7). Rows carry
aria-level; an expandable row carriesaria-expanded+ a
data-hc-datagrid-treelead-cell toggle, and the table upgrades to
role="treegrid". A lazy row's first expand marksdata-loaded,
setsaria-busy, and dispatcheshc:datagridtreeload— htmx
GETs the children and inserts themafterend, one level deeper
(empty answers the contract's single empty-state row). Collapse /
re-expand of loaded subtrees is client-side visibility (collapsed
children respected; hidden rows leave keyboard navigation) and emits
hc:datagridtreetoggle{ row, expanded }. Levels 2–4 indent
via--hc-datagrid-indent. Machine-checked contract; live docs demo
(en/ja). -
datagrid: native validation gates the inline-edit commit
(plans/hc-datagrid-enrichment-plan-en.md§1.9). An editor template
control carryingrequired/pattern/min/max/maxlength
must satisfy them before the value is written back — the editor stays
open with the nativereportValidity()message, nohc:datagridedit
fires, and type-to-edit on another cell won't abandon an invalid
editor.Escapestill cancels; combobox picks bypass (options are
valid by construction). No new attributes — the constraints are the
API. -
datagrid / table: declarative conditional formatting via
data-tone="info | success | warning | error"on a cell, a row, or a
record<tbody>(plans/hc-datagrid-enrichment-plan-en.md§1.8).
The server evaluates the rules and renders the outcome as the
attribute; the CSS paints it — datagrid via the new token-backed
--hc-datagrid-tone-<tone>-bg/-fg(frozen-safe gradient),hc-table
via the shared--hc-color-status-*semantic colors. Dark-theme
aware; under forced colors the tint becomes a dotted outline. -
datagrid: grouped rows — server-rendered, client-toggled
(plans/hc-datagrid-enrichment-plan-en.md§1.6). The server
interleaves.hc-datagrid__grouprowheading rows (onecolspancell
with the label and any aggregates it renders);installDatagrid()
toggles the group on click / Enter / Space via the heading cell's
aria-expanded(render"false"to start collapsed), with a▸/▾
caret,data-group-level="1…3"nesting (collapse runs to the next
same-or-higher heading; re-expanding keeps collapsed sub-groups
collapsed), andhc:datagridgrouptoggle{ row, expanded }.
Headings join keyboard navigation but are not selectable units, and
collapsing never changes the selection. Nothing is grouped or summed
client-side. -
datagrid: multi-column sort
(plans/hc-datagrid-enrichment-plan-en.md§1.4).Shift+Click/
Shift+Enteron adata-sortableheader adds the column to the
sort set (a plain activation stays single-column and clears the
rest). With two or more sorted columns each header carries
data-sort-index="1…n"and the indicator shows the ordinal (↑1,
↓2).hc:datagridsortdetail gainssorts— the full ordered
set as[{ col, direction }, …](the existingcol/direction
fields are unchanged); the conventional wire format is
?sort=name,-price. -
recipes:
datagrid-filter— per-column filter entry for the
datagrid (plans/hc-datagrid-enrichment-plan-en.md§1.5). A
filter-popover off a header cell's trigger button GETs the grid URL
with namespacedf-<col>params; the server re-renders the grid
filtered (the trigger rides back inside the fragment,data-filtered- an
aria-labelnaming the active values) plus an OOB re-render of
the form's fieldset with matching checked states. Filters compose
across columns via server-rendered hiddenf-<col>inputs. Zero new
JavaScript; machine-checked contract; live docs demo (en/ja).
- an
-
datagrid: sticky aggregate footer and trailing frozen columns
(plans/hc-datagrid-enrichment-plan-en.md§1.3). A
<tfoot class="hc-datagrid__foot">pins to the bottom of the scroll
viewport (multi-row footers stack via the measured
--hc-datagrid-foot-1-h), styled like the header band — the server
renders the aggregates, the CSS only pins them.data-frozen-end/
data-frozen-end-edgemirrordata-frozenon the trailing edge
(RTL-aware, per-cell--hc-datagrid-rightmeasured by the behavior;
new--hc-datagrid-freeze-end-shadow/--hc-datagrid-foot-shadow
knobs). Footer cells carry grid roles but stay out of keyboard
navigation. -
datagrid: spreadsheet-style range selection and clipboard copy
(plans/hc-datagrid-enrichment-plan-en.md§1.2).Shift+Arrow/
Shift+Clickextend a rectangular cell range from the active cell
(cells carrydata-in-range, painted with the selection tint;
Escapeor any plain move clears it, as does an htmx row swap).
Ctrl/Cmd+Ccopies the range — or the active cell alone — as TSV
after a cancelablehc:datagridcopy({ text, rows, cols };
preventDefault()claims the clipboard write).Ctrl/Cmd+Aselects
every row on the page through the select-all path instead of
selecting the document. -
datagrid: type-to-edit is now IME-safe. Composition keystrokes on
an editable cell (isComposing, keyProcess, or keyCode 229) open
the editor unseeded and withoutpreventDefault(), and a
compositionstartlistener covers engines that fire it before any
usable keydown — CJK input is no longer swallowed by the cell or
corrupted into a raw latin seed character.
Full details in CHANGELOG.md.