Skip to content

chore(release): 1.3.0 - #39

Merged
ndlabdev merged 21 commits into
mainfrom
dev
Aug 24, 2026
Merged

ndlabdev merged 21 commits into
mainfrom
dev

Conversation

@ndlabdev

@ndlabdev ndlabdev commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

Cut for 1.3.0. The release is taken from dev, so this pull request carries the work and the
version bump together; the tag is pushed from main once this merges, and that tag is what
publishes.

What ships

Added

  • Header groups fold. columnGroupShow swaps the detail columns for the summary one, and
    collapseMode: 'rail' takes the whole group away behind a drawer the height of the grid.
    Every level has its own toggle, and the fold is on the keyboard, in the column menu, in the
    events and in the snapshot.
  • A filter row under the header, filtering({ floatingRow: true }): one control per column
    type, sharing the column's single filter panel rather than opening a second one.
  • GridFeature.cellValue, which stands between a cell's value and every way it leaves the
    grid: render, export, clipboard, quick filter, set filter and the editor's draft.
  • CellDecoration.style, so a feature can write CSS declarations computed per cell, custom
    properties included, instead of picking from a fixed set of classes.
  • DataGridLabels.anyValue and DataGridLabels.filterRowValue, in all twelve packs.

Changed

  • A date condition in the filter panel is picked with the grid's own date picker rather than
    the browser's control.

Fixed

  • A date typed one segment at a time is the date that was typed, and a year under a hundred
    is that year rather than nineteen-hundred-and-it.
  • A row edit commits only the columns that row may edit.
  • One filter panel per column, wherever it was opened from.
  • Focus stands where the grid still draws: putting the focused column away used to leave the
    grid with no tab stop at all.

Every entry has its paragraph and its reasoning in CHANGELOG.md.

Also in here, from the tail of 1.2.0

This branch has been open since the docs change below, which ships with the rest of it.

docs/ holds notes written for whoever is building this: a phase plan, a write-up of a
popover bug in the dependency, and a feature inventory read off the source. None of it is
documentation for someone installing the package, and this repo is public, so leaving it
there published all of it by default.

Untracked rather than deleted. The files stay on disk and .gitignore keeps them from
coming back. Nothing in the repo referenced them, and files in package.json ships dist
alone, so the tarball never carried them.

Also carries the release-notes fix and the token guidance from the 1.2.0 release, which
have been sitting on dev.

One thing this does not do: the files remain in history. Every commit and tag that had them
still has them, so blob/v1.2.0/docs/PLAN.md reads fine on a public repo. Removing that
means rewriting history, which breaks every clone and moves every tag, and does nothing
about what has already been fetched or indexed. Worth it only if something in there is
genuinely sensitive rather than merely untidy.

docs/ holds notes written for whoever is building this: a phase plan, a
write-up of a popover bug in the dependency, and a feature inventory read off
the source. None of it is documentation for someone installing the package,
and the repo is public, so leaving it there published all of it to strangers
by default.

Untracked rather than deleted: the files stay on disk and .gitignore keeps
them from coming back. Nothing referenced them, and `files` in package.json
ships dist alone, so the tarball never carried them either.
A class names one of a fixed set of looks. It cannot name a value computed
per cell, which is what a colour scale, a data bar or a per-user cursor tint
is — so a feature that wanted one had to reach outside the extension point,
which is the same as saying the extension point was not enough.

`CellDecoration` takes a `style` record now, keyed by CSS property, custom
properties included: `{ '--dg-bar': '42%' }` is how a feature reaches a
pseudo-element without an element of its own. Several features decorating the
same cell merge per property with the later one winning, matching how their
classes already concatenated.

The cell keeps its own layout either way. The grid writes the grid column, the
pinned offsets and the editor's padding as style *directives*, which outrank
the attribute a decoration lands in, so a decoration can paint a cell but
cannot move it out of its column, unpin it or escape the row — there is a test
holding that line. A value is cut at the first `;` as well, so one entry stays
one declaration and a colour read out of row data cannot open a second.

The playground grows a heatmap on the metrics grid, which is the whole feature
in nine lines.
`openFilter` carried a single ellipsis glyph while `search`, three lines above
it in the same file, carried three dots. Both are the same pause, and one of
them is typeable, so both are three dots now, across all twelve packs.

The chip for a numeric range used an en dash between the ends. A hyphen reads
the same and can be typed.

Left alone, because there the character is the content rather than the
typography: the em dash a blank cell draws, and the >= symbols that are the
filter operators themselves.
The same three places sv5ui puts them, so the two repos read alike: a
FUNDING.yml for the Sponsor button GitHub draws on the repo page, a badge
beside the other three in the README, and a `funding` field in package.json,
which is what makes npm print the line on install.

Indentation follows this repo's prettier rather than sv5ui's, which is the
only thing that differs.
`cellDecoration` paints a cell; it cannot change what the cell is. A feature
that had to hold a value back could therefore grey it out and watch it leave
through the clipboard, the CSV, and the text a quick filter searches.

`GridFeature.cellValue` is asked per column and purpose and returns a reader
for the cells it substitutes. Six exits go through it: the cell and its
tooltip, export, the clipboard, the quick filter's text, the list a set filter
offers, and the draft an editor opens with. `grid.readerFor(columnId, purpose)`
is how a pass that reads a whole column asks once and then loops;
`grid.getValue` takes the purpose and defaults to what the cell draws.

Identity is the contract: hand the value back unchanged for a cell left alone.
The grid reads that to settle editing as well, since a cell whose value was
substituted is one an editor would commit the substitute over, so it does not
open. A batch drops such a cell rather than writing it, which is what a paste
across a masked column now does.

Sorting and filter predicates keep reading the raw value. A gate in the sort
would undo the single-pass comparators of 1.2.0, and a predicate deciding which
rows survive cannot decide on a substitute, so a masked column is still
orderable and still narrowable: take `sortable` and `filter` off it. The two
caches that hold results per column, the search text and the set filter's
value list, are now keyed by the reader as well, so swapping one does not
answer out of the list built for the other. A guard test keeps `getCellValue`
behind the door so a seventh exit cannot grow back unnoticed.

The playground route /value-gate shows all six exits changing at once.
`applyEdits` drops a cell the column will not take; `commitRow` wrote every
draft it held. Through the UI the two agreed, since a row edit only opens
fields `editableAt` allows and only those fields write drafts. Through
`setRowDraft` they did not, so a draft set by hand on a column a value gate is
holding back, or on one that is simply not editable, reached the data.

Also pins what a built-in renderer does with a substitute it cannot draw: a
string on a currency column parses as no number and the cell draws empty,
`null` draws the column's empty text. Nothing leaks either way, but the mark
only appears where the renderer can show one, so a gate on a typed column has
to answer in that type. Said in the type doc and in the README.

Adds the coverage the first pass left out: the cell in the DOM and the markup
behind it, the tooltip and `cellClass` callbacks, a column the grid does not
show but an export names, a gate that hides one row of a column and not the
next, a formatted copy, and the row-edit door above.
The gate was proven on a plain client grid, which is the one arrangement
where the rows on screen are also the rows an export writes.

Server mode: the filter stage returns the page whole there, so the search
path a gate closes on the client is not even walked. What is left is the page
in hand, and that is what a user copies. Covered: the page it holds and the
page it turns to, the copy and the export of the loaded rows, the set filter's
list, the cell that will not open, and the quick filter staying a pass-through
whose query is the string the user typed rather than anything read off a cell.

Client paging: `allRows` reaches past the window, so twenty rows come out
gated while five are on screen, and a search crossing pages finds the
substitute rather than the value.

Virtualization: rows drawn for the first time after a scroll carry the
substitute too, with the value absent from the markup on both windows.
…yped

A segmented date field reports every keystroke, so a year on its way to 2026
arrives as 2, then 20, then 202. Three layers each dropped the leading zeros,
and between them a half-typed year became a string nothing here can read.

`fromDateValue` wrote `2-01-05`. `toDateValue` built the same shape before
handing it to `parseDate`, which refuses it, so a field controlled by what it
had just reported was handed nothing back and cleared itself. And `toDate`
read a year under a hundred as nineteen-hundred-and-it, the rule
`new Date(y, m, d)` has carried since 1995, so `0002-01-05` came back as 1902
and a field being typed into jumped there.

What it cost: a date typed into the filter panel ended with no filter at all,
and a cell editor left behind on blur wrote `2-07-04` onto the row, into the
snapshot and out to the server. All three now pad to four digits, and `toDate`
sets the year explicitly rather than letting the constructor guess at it.

Tested at each layer: the helpers round-trip every year from 2 to 2026, the
parser keeps 0002 and 0099 out of the 1900s, and a cell editor left mid-year
commits `0002-07-04` rather than a string no parser reads. Wrong, as any
half-typed value is, but a date.
The column filters were a panel behind an icon. Everything else in the free
tier is on the surface; the one thing a person reaches for first was two
clicks away, and every grid it gets compared against puts it in a row.

`filtering({ floatingRow: true })` draws that row, and `<DataGrid
floatingFilters />` is the same for a grid it builds itself. Each field
filters in the operator the column already uses, so an operator chosen in the
panel survives the next thing typed in the row, as does a Match case it turned
on, and a percent column is written in the unit it draws.

What one field cannot hold stays with the panel rather than being flattened to
fit: a set of discrete values, two conditions joined, a `between` range, and
`blank` or `notBlank`, which an empty field would report as no filter at all.
The row shows what those contain in the words the chips use, next to a button
that opens the panel on them. This is also where the tier line runs, and it
runs in the same place as before: one condition per column here, everything
nested in the panel.

It is a row of the grid rather than a strip above it, which is the part that
reached into the kernel. `FILTER_ROW` is a second navigable line: the linear
walk the focus model does across header, pinned top, body and pinned bottom
now starts with one or two header lines instead of always one. Arrow down from
the header lands in the field, arrow down again is the first body row, and the
cell passes focus on to the field rather than swallowing it, because a row you
can reach and not type into is not a filter row. Left and right stay with the
caret. `aria-rowcount` counts the row and the rows below it are numbered under
it, both through one `headerRowsOf` rather than three copies of the same sum.

The row follows the same column window, pinned offsets and group dividers as
the header above it, so it stays with its columns when the grid scrolls
sideways. `Grid.FilterRow` is the part for a hand-assembled grid, and the flag
is live: turning it off mid-session takes the row out of the keyboard grid and
out of the numbering with it.

Twenty-five tests: what each filter shape offers a cell, the focus model with
and without the line, and the row itself in a browser down to axe. The data
ops demo opens with it and can turn it off.
The filter row drew its own `GridFilterPanel` for a column it hands back to
the panel, and the header already has one. A panel reads whether it is open
from `filterFor`, which names a column and not an instance, so both answered
and a set column opened two panels stacked over each other.

The row asks instead of drawing: its button sets `filterFor` and the column's
one panel opens on its header. The header controls now also stay visible while
that panel is open, since the panel is portalled out of the header and takes
focus with it, so `focus-within` cannot hold the trigger it is anchored to on
screen. That was already true of a panel opened from the column menu, which
hung under an invisible button until the pointer came back.
`anyValue` is the choice that filters nothing on a column whose filter is a
choice. It had been borrowing `clear`, which reads as an action rather than as
a state, and left the control saying "Clear" while filtering nothing at all.

`filterRowValue` names the field in the filter row. It had been borrowing
`filterColumn`, which is the panel trigger's name, so one column carried two
controls answering to "Filter Active" and a screen reader had no way to tell
the choice from the button that opens the panel.

Twelve packs, as ever, and `locales.test.ts` holds them to it.
…browser

`Input type="date"` drew whatever the browser draws, in a grid whose own date
cell editor has always mounted the sv5ui `DatePicker`. The condition editor
mounts it too now, for the value and for the upper bound of a range, and takes
its accessible name through an `id` a label points at, which is the way the
picker asks to be named.

The calendar opens into a portal, and the panel's click-outside handler
already knows a portal is still itself. Nothing stood on that before; a test
does now, and types a date segment by segment as well, which is the path that
was ending with no filter at all until the layer below it was fixed.
The row shipped with a text box for everything, which was wrong in five ways
and hid a sixth.

A boolean column drew an empty control. Its "no filter" choice carried the
value `''`, which a select reads as nothing selected, so the trigger rendered
blank with no way back to it. It has a value of its own now, and the row's
fields are named apart from the panel's trigger, which they had been
colliding with on every column.

A number types into `InputNumber` with the steppers out of the way, a date
into `DatePicker`, and a set of values into a searchable multi-select rather
than a button that sent the reader to the panel for what is still one
condition. The list reads the column's values only when it is first opened: a
pass over every row is not something to spend on drawing a row nobody has
touched, and a test counts the reads to keep it that way. Its placeholder is
also darkened, sv5ui's own being too faint to pass a contrast check.

All of them wait the same 200ms before the model hears them. The date field
had been writing on every keystroke, so typing 01/05/2026 filtered on the
years 2 and 20 first, and reading the model back while typing fought the
segments until the filter cleared itself.

Two more the review turned up: switching the row off while the caret stood in
it left the grid with no tab stop at all, and arriving on a date cell by
keyboard landed on the calendar button rather than on the first segment.

`GridFilterCell` moves to `cells/`, beside the other things drawn inside a
cell, and the demo, the README, the feature notes and the slot list say what
the row now does.
A group's children say which side of the fold they stand on with
`columnGroupShow`, and the group says how it folds with `collapseMode`.
Folding to a summary swaps the detail columns for the one that stands for
them, and the group's header stays where it was. Folding to a rail takes the
group away entirely, header and cells alike, and leaves a drawer the height
of the grid with the name down its length, which is what a group with no
summary column needs and what gives a wide grid its width back.

Every level carries its own toggle, so a nested group answers for itself and
keeps the state it was left in while the group above it folds and unfolds.
The fold is reachable from the column menu and from the keyboard, it is
announced, it rides in the snapshot, and it is on the api as `toggleGroup`
and `setGroupCollapsed` with a `columnGroupToggled` event behind them.

The drawer is one band from the top of the header to the last row. The header
draws the head of it, over its own cells and over the rules between them; the
strip draws over the row lines below; it follows a pin the way the cells do,
travelling no further than they travel; and it draws both of its own edges
except where the grid's own border is already standing on one.

Closes #40
# Conflicts:
#	CHANGELOG.md
#	README.md
# Conflicts:
#	CHANGELOG.md
#	README.md
#	src/lib/components/grid/GridBody.svelte
#	src/lib/components/internal/window.ts
#	src/lib/core/interaction/focus-model.svelte.ts
#	src/lib/core/interaction/focus-model.test.ts
`openFilter` lost its ellipsis character to the keyboard-characters pass
while the filter row was being built on another branch, so the two met here
with the test still asking for the old label.
Three release merges that were never brought back. They carry no content
dev does not already have; the release preflight needs the history joined
before it will cut the next one.
@ndlabdev ndlabdev changed the title chore: keep the working notes out of a public repo chore(release): 1.3.0 Aug 24, 2026
@ndlabdev
ndlabdev merged commit 7c477ef into main Aug 24, 2026
2 checks passed
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