Skip to content

Add group header and group banner - #1

Merged
MatthewHsu1 merged 15 commits into
mainfrom
feat/grid-header-bar
Aug 23, 2026
Merged

MatthewHsu1 merged 15 commits into
mainfrom
feat/grid-header-bar

Conversation

@MatthewHsu1

Copy link
Copy Markdown
Owner

No description provided.

The stylesheet was nine hand-written rules. The trigger for switching was
always "when the second real UI surface lands", and the group banner, the
column picker and the toolbar are that surface.

Consumers see no change: same `@matthewhsu1/datagrid/datagrid.css` import,
same `dg-` class names for the one rule that stays a class.

Three things are load-bearing in `src/datagrid.src.css`:

  - Preflight is deliberately left out. `@import "tailwindcss"` pulls in a
    reset, and a package that resets its host's margins and form controls is
    a package that vandalises the app installing it. Importing `theme.css`
    and `utilities.css` separately takes the utilities without the reset.

  - `prefix(dg)` namespaces every utility and every theme variable, so a
    host app's own Tailwind and ours cannot collide.

  - `@source` is what makes any of this work. Tailwind builds its output by
    scanning source, and it never scans `node_modules` — which is why a
    package cannot ship unprefixed utilities and hope the consumer emits
    them. Scanning at build time and shipping the result closes that hole.

`Input` gains an `invalid` prop rather than taking a `dg-input--invalid`
class from outside. Tailwind orders its output by utility, not by the order
class names appear on an element, so an override layered on a base would be
decided by the generated stylesheet instead of by the component. The two
states are now whole, mutually exclusive strings.

The demo aliases the package stylesheet to the SOURCE and runs Tailwind
itself, so the demo stops styling itself the moment that source stops
compiling.
The same component will render in a hole in the grid canvas and in the bar
above it, which is what stops the pinned copy and the in-grid copy drifting
apart. Nothing mounts it yet.

It takes a RESOLVED css colour rather than a Radix scale. Turning a scale
into a colour reads vars off the live Themes root — a document lookup — and
keeping that outside leaves this a pure function of its props, renderable in
both places without either caring where it is.

That lookup moves to `groupHeaderColor.ts`, which `groupHeaderCell` now
shares. One source of truth for "what colour is this group's name" while the
canvas cell and the DOM row both exist.

The collapse control is a real button with a real accessible name, rather
than the cell-click the canvas header needs.
`GridStatusBar` rendered nothing whenever there was nothing to report, so
the grid body moved under the pointer every time a load settled. The bar is
now always there, and the status is one ZONE inside it rather than the whole
of it.

Three zones: the group the user is currently inside, the status, and the
column picker. The group zone renders the same `GroupHeaderRow` the in-grid
headers will, minus the host's components — those get a second row here,
because the status and the picker have already spent the first one.

One deliberate change of behaviour beyond the move: a failed load no longer
replaces everything above the grid. It fills the status zone instead, so
losing rows never also costs the user the control that decides which columns
the grid shows. Every other status precedence is unchanged, and its reasoning
moved across with it.

`group` is wired to null at both call sites. Nothing computes the current
group yet.
Show and hide only, plus a reset. Reordering stays a drag on the column
itself: two ways to do one thing is two sets of edge cases, and a drag here
disagreeing with a drag there about where a column went is the obvious one.

It lives in the bar rather than in a context menu on the header row, because
a hidden column leaves no header to right-click — the only way back would be
a control the user cannot see.

The grouped column is locked. Hiding it would leave the banner naming a
group with no column anywhere to point at.

Two things the picker needed that did not exist:

  - `resetColumns`, which restores the descriptor's order and clears widths
    and hidden. It COPIES `defaultOrder`; handing out the descriptor's own
    array would let the first drag after a reset rewrite the descriptor, and
    every later reset would then restore the dragged order.

  - `resetColumns` in the persistence effect's watch list. Without it a reset
    looked like it worked and then handed the thrown-away layout straight
    back on the next load.

Split in two so it can be tested: `ColumnPickerList` holds every decision and
renders with a plain mount, while the popover shell holds none. A popover
needs `ResizeObserver`, which jsdom does not ship — there is now a stub for
it in the test setup, since the tests that mount one assert on what is in it,
never on where it was placed.
The shell was Radix and the rows were browser defaults, so the popover would
have rendered half in the app's design system and half not. Radix
`Checkbox`, `Text`, `Badge` and `Button` throughout now.

A Radix checkbox is a button, and a wrapping `<label>` gives a button no
accessible name, so each row names its checkbox with `aria-labelledby`. The
lock badge is part of that name rather than decoration beside it: "why can I
not hide this column" should reach a screen reader too.

The id scope comes from `useId` and not from the field. Field names are
unique within a grid but not across two grids on one page, and
`aria-labelledby` resolves against the whole document.

One test added, for the behaviour the swap could quietly have cost: clicking
a row's TEXT, not just its box, still toggles the column. It passed on the
first run, so it was checked the only way that means anything — by breaking
the label wrapper and watching it fail.
A canvas is a picture, and a picture cannot hold a component. So the canvas
stops drawing the group's name and draws the SPACE for it: full-width, no
text, filled from the theme's header background. `groupHeaderCell` is gone;
`groupHeaderHole` takes its place.

The hole is still filled and still spans every column. The canvas paints
first and React commits after, so an unfilled hole would flash the ordinary
cell fill on any frame the layer has not caught up with, and an unspanned one
would draw column borders straight across the header.

Nothing in the display model changes. A header still occupies exactly one
display row, and every position sum in the engine is untouched.

Three pure pieces the layer needs, each with its own suite:

  - `headersInDisplayRange` — the headers a viewport can see. Reads segments
    rather than walking rows, so a tall viewport costs one pass over the
    groups instead of one lookup per row. A COLLAPSED group is included: its
    header is the only way back to its rows.

  - `groupAtDisplayRow` — the group a row belongs to, header or data. The
    banner asks this about the first visible row, which is what will make it
    name the group the user is INSIDE rather than the last header they
    scrolled past. The two differ for every group taller than the viewport.

  - `rowHeightFor` — a header row gets `headerHeight`, everything else the
    grid's row height.

Two `useCellRenderer` tests now assert the opposite of what they did: the
header cell must paint no name and must ask `grouping.label`/`color` for
nothing. A name painted here would sit under the React one and show through.
`GroupHeaderLayer` fills the holes the canvas now reserves. This is what
makes host components possible in a group header at all: a canvas is a
picture, and a picture cannot hold a button.

Two owners, deliberately split. React owns WHICH headers exist — a set that
changes only when the viewport crosses a group boundary, a few times per
scroll. The DOM owns WHERE they are, through an imperative `place`, called on
every visible-region change glide reports.

Routing positions through React state instead would re-render the layer on
every scroll frame and leave each header trailing the rows it names. This is
the same bargain `useRepaintRows` already strikes for changed rows: React for
what exists, direct writes for what moves. There is a test that place() costs
no render.

A header starts hidden and stays hidden until it is placed. React commits a
new header before its placement arrives, and one drawn at zero in between
would pile up over the grid's first row.

The layer takes no pointer events and each header takes them back. A header
covers only its own hole, but the layer spans the whole grid, and a layer
that swallowed clicks would make every row under it unselectable.

Nothing mounts it yet.
@MatthewHsu1
MatthewHsu1 merged commit c73f10a into main Aug 23, 2026
1 check passed
@MatthewHsu1
MatthewHsu1 deleted the feat/grid-header-bar branch August 24, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant