Skip to content

feat: the sixteen modules, nine parts and the xlsx entry, for 2.0.0 - #47

Merged
ndlabdev merged 43 commits into
devfrom
feat/merge-w0-core
Sep 11, 2026
Merged

ndlabdev merged 43 commits into
devfrom
feat/merge-w0-core

Conversation

@ndlabdev

Copy link
Copy Markdown
Owner

Summary

Forty-three commits carrying everything ## [Unreleased] describes. Sixteen
feature modules, nine parts to draw them with, a second entry point at
@sv5ui/datagrid/xlsx, and the fixes found while the documentation was being
written against them. Every module is opt-in the way the nine before were, and
bundle-shape.test.ts builds a real entry to prove that a feature nobody
registered is code nobody bundles.

The version is not bumped here. npm run release -- major does that from dev
once this lands, and semver asks for 2.0.0: two entries under Changed are
breaking and a consumer can observe both.

What a reader of 1.3.1 should know

MIGRATING.md is written for them. Four things are not additive:
the label table grew by 147 required members, a blank cell now passes neq on
a number column, a typed column draws text it cannot parse instead of an empty
cell, and the loading skeleton gives its cells role="gridcell". Each has the
diff that fixes it.

Checks

  • npm run lint, npm run check: clean, 0 errors.
  • Unit and benchmarks: 1919 passed, 13 skipped. Browser: 714 passed.
  • npm run release:verify: tarball publishable, 530 files, no test files,
    every export resolves, GridApi augmentations survive in 21 files.
  • Public surface against 1.3.1: 40 names added, 0 removed.

The full account of each change is in CHANGELOG.md.

The sixteen feature modules coming over next all read cells, mark
synthetic rows, coerce numbers or name a string, so the ground they
stand on has to be here before any of them are.

Placed by what each thing is rather than by where it sat: the row marks
join `row-node.ts`, the memoized reader joins `value-gate.ts`, and the
URL codec becomes `share-link.ts` because `snapshot.ts` next door
already means something else.

Two duplicates die on the way in. `date-value.ts` is the weaker twin of
`components/internal/editor-values.ts`, and `resolve-locale.ts` matched
`core/interaction/locale.ts` line for line.

The two label tables and the two slot tables become one of each: 214
keys across twelve languages, 140 slots. Both merges collided on the
same word. `filterColumn`, `filterOperator` and `filterValue` already
name the filter panel's aria-labels, and `filterPanel` and `filterRow`
already name the panel and the floating row, so the filter builder's
own nine slots and three labels take a `filterBuilder` prefix. One
label, for an overlay the grid no longer draws, does not come over.

The i18n guard is what holds the merge honest, so it grew with it:
plausible arguments for 43 more function labels, and ten loanwords
named one by one in German, French and Indonesian rather than exempted
in bulk.

Budgets move to a serial project of their own. They were running
alongside 900 unit tests, which is how a timing assertion learns to
flake.
Three defects the review found, each with a guard that fails without
the fix.

`gateReader` cached the column but not the composed reader, so
`grid.getValue` asked every gate for a reader once per cell rather than
once per column: 15,000 compositions for 15,000 reads over 5,000 rows
and three columns, against three now. Its own docstring described the
behaviour it did not have.

`canonical` rebuilt every object from its entries, which turns a `Date`
into `{}`. A share link lost the value and `sameSnapshot` called two
different snapshots equal, while the same slice through `localStorage`
kept it, so the two paths a snapshot travels disagreed. It now honours
`toJSON` the way `JSON.stringify` does.

`autoColumns` guessed `date` from the shape of a string, so a column of
`1234-56-78` part numbers typed as dates and then drew every cell
blank, because the renderer asks `toDate` and gets nothing. The guess
now asks `toDate` too.
Grouping, formula, the filter builder, range selection, find and
replace, the import wizard, master/detail, saved views, policy, tree,
show-values-as, conditional formatting, the server row model, xlsx, the
command palette and the worker row model, with their tests beside them.

They arrive nearly as they were written: every one consumed the grid
only through its public extension points, so the work was pointing the
imports at the folder barrels the existing features already use, and
sending the declaration merging at the module that declares `GridApi`.

Three things they had of their own become things they share.

Labels were read from a global. They now come off `grid.labels`, which
is the reader the rest of the library uses and, unlike the global, says
what this grid's language is rather than the application's.

Slot classes were read from a second table. That table is now the same
one, moved down to `core/theme` so a feature can name a slot without
importing from the layer that imports it, and `slotClass` is what a
`cellDecoration` asks. The whole-app config moved with it; its old path
re-exports, so nothing an application imports has moved.

`isBlank` was written out four more times, once per module that needed
it. There is one.

Two of them were reading past the value gate, which the guard in
`value-gate.test.ts` is there to notice. Both had already built the
reader and then not used it: find matched on raw values, so a masked
column stayed searchable and a hit pointed at a cell the user cannot
read; and a colour scale or data bar was measured against real numbers,
which draws the hidden value as a bar width. Both now read through the
gate they hold. The worker is listed as allowed instead: it filters and
sorts, which RFC EP5 §7 already records as deliberately open, and it
takes a `readValue` for callers who want otherwise.

The saved views' own `share-link.ts` becomes `share-param.ts`, the
kernel having taken that name for the codec, and its storage key drops
a suffix that named a tier.
The sixteen modules arrived exporting everything they happened to
define: 306 names across sixteen files, one of them naming forty-seven.
A barrel is a decision about what the rest of the library may reach
for, and that was no decision at all.

Each name was checked against three questions: is it part of the
surface an application is meant to see, does anything outside its own
module import it, and is it one of the four every feature barrel here
carries - the id, the class, the factory and the accessor. 137 names
answered no to all three and are gone. The modules' own files never
went through the barrel to reach each other, so nothing moved but the
lists.

Three survivors were false positives worth naming, because a search by
name alone will keep finding them: `MAX_DEPTH` in the formula parser
was kept by the filter builder's own `MAX_DEPTH`, `boundsOf` in range
selection by the one in the xlsx writer, and `parseDate` in the import
wizard by `parseDate` from `@internationalized/date`. Same words, three
different things. They are gone too.

While in the same files, the shared blank test finishes the job the
last commit started: eight more copies of `value === null || value ===
undefined || value === ''`, under three different names, become the
`isBlank` the library already had. `numericOrNull` reads it too.

Range selection stops declaring a `CellPosition` of its own. The focus
model's is the same `{ row, col }` with an optional `section`, so the
cell the user is standing on can be handed straight to
`selectCellRange`, and the public surface will have one name for one
idea rather than two names for nearly one.
… them

The chrome the new modules drew comes over as chrome: a group panel, a
filter builder, find and replace, saved views, an import wizard, a
command palette, a conditional formatting panel, a range status bar and
a tool panel, plus the two internal parts the builder is made of.

Each was rewritten to the shape the parts here already have. A `grid`
prop and a `ui` prop become the grid and the theme this grid already
put in context; a second class table becomes `datagridVariants` and
`getGridTheme`; a global label reader becomes `grid.labels`.

The wrapper component that sold the parts does not come over at all,
and neither does the overlay it drew; the dependency that served them
is gone with it.

The third thing that wrapper did needed somewhere to go, and it is the
only new extension point here: `GridFeature.component`, a component the
grid mounts inside its root. A feature is built inside `createDataGrid`,
where there is no effect context and no DOM, so a feature that needs an
effect or a listener had nowhere to put it and an application had to
mount the part by hand and know to do so.

Naming the component on the feature is what keeps the promise on the
box. `DataGrid` imports none of these, so a feature nobody registered
is still a component nobody bundles - and the two that use it stop
being an application's problem. `rangeSelection()` contributes the
layer that reads its pointer gestures, which now listens on the grid's
own root rather than wrapping it in a div of its own, and
`serverRowModel()` contributes the one that fetches, with the two
callbacks it used to take as props moved into its options where the
rest of its configuration already lived.

`feature-layers.svelte.test.ts` is what holds all of that: a grid built
with two features, asserting that the layer attached itself and that a
panel handed no grid still drew one.
…ey arrived

Three things were wrong with where the new parts landed.

`chrome/` had become seventeen files covering two different jobs. A
toolbar control is fifteen to sixty lines, belongs in a row the grid
lays out, and has one place it goes; a panel is a hundred to three
hundred lines and is a piece of the page an app puts where it likes.
The eight of the second kind move to `panels/`, and `chrome/` goes back
to meaning the grid's own frame. `RangeStatusBar` stays: it is a status
bar, and it sits in the footer beside the other one.

`FilterBuilderGroup` was in `chrome/` while the other half of the same
builder was in `internal/`, and neither is exported. Both are internal
now, in one place, so `chrome/` and `panels/` hold only what leaves the
package.

`RangeLayer` and `ServerRows` were in `components/grid/` and imported
by the features that name them, which pointed `features/` at
`components/` and closed a cycle between the two. Each moves into the
feature that ships it, where the import that was crossing layers is now
one folder talking to itself. What is left is those two components
reading the grid out of context, which is a leaf module and no cycle;
the reason that one is allowed is written where it happens.

Neither is exported. An application never mounts them - the feature
does - so exporting them would be publishing wiring.

`SavedViews` stops writing its own clipboard call and its own copied
flag. sv5ui's `useClipboard` has both, and its `copied` clears itself
after a couple of seconds, which fixes a real thing: the "link copied"
message used to sit there until the user happened to save a view. A
refusal keeps its own state, because that one has to stay up.

The import preview keeps its plain table, now with the reason written
down. sv5ui's `Table` sorts, filters, selects, pins and resizes; asking
it to show five rows of a file would put a second table engine in the
bundle of a data grid.
The sixteen modules were in the package but not reachable from it. The
three barrels now name what an application is meant to call: the
factory, the accessor, the state and the options for each module, the
nine panels, `autoColumns`, `isDataRow` and `ShareTooLongError`.

Five states take a `...State` suffix because a component of the same
name draws them and one word cannot mean both, and `CellPosition` is
absent from the feature list on purpose: the kernel exports it, and
range selection now uses that one.

The workbook writer gets an entry of its own at `@sv5ui/datagrid/xlsx`.
No grid is involved in `createWorkbook`, and nobody who wants a
spreadsheet should reach through a data grid to find one.

The old contract test is deleted. It pinned a boundary for a consumer
that no longer exists, and a test that guards a boundary which no
longer exists is a test that passes for no reason.

What replaces it is the guard the README has needed since the module
count went from nine to twenty-five. `bundle-shape.test.ts` builds a
real entry that registers one feature and asserts that the formula
evaluator, the workbook writer, the import wizard's unzip and the
worker's row store are all absent from the output; then it builds a
second that does name the formula, and asserts it arrives. Without the
second half the first would pass on a typo.

Both hold. `publint` is clean and the subpath is in the tarball.
Seven of the twenty-five, the ones that neither mounted the old wrapper
nor asked about its gate: the feature interplay checks, the filter
builder, the hostile values, the server contract, the tool panel, the
worker row model and the xlsx export.

Two things had to change in every one that draws.

A part is no longer handed a grid. `InRoot.svelte` is the smallest
thing that stands in for the application around it, and the error the
old way now produces - "Grid parts must be used inside <Grid.Root>" -
is the component saying so itself.

The DOM contract lost a suffix that named a tier: the filter row's
attribute is `data-dg-filter-row` now, in the markup and in the tests
that read it.

The eighteen that remain are the ones built around the old wrapper and
its gate, which is the work of swapping a component that no longer
exists for the one that does.
… exists

Ten more: grouping, find and replace, range selection, show-values-as,
tree and detail, conditional formatting, the rule panel, the import
wizard, the server row model and formula editing. The old wrapper becomes
`DataGrid`, its gate tests go, and a part is mounted inside a root
rather than handed a grid.

Two of them found real defects rather than needing adaptation, which is
the reason they were worth porting.

A server grid said "No data" during its first request. The old wrapper
used to pass the model's loading state down and nothing replaced it
when it went. `GridState.status` is what a feature that owns the rows
sets instead: the body reads one field, a prop still wins, and
`GridBody` never names `serverRowModel`, so the bundle is unchanged.

The loading skeleton drew `role="row"` around plain divs. A row owning
no `gridcell` is a row a screen reader cannot read, and axe says so as
soon as a grid is both loading and holding rows, which a server grid
routinely is. The cells have the role now.

That last one changed a test here that had pinned the defect: it
asserted zero gridcells while loading. It now asserts what it meant -
that cells are on screen and every one of them is empty of data.

Two more tests pinned mechanisms rather than behaviour and were
rewritten to the behaviour: the fill drag suppresses text selection
through a style on the grid root rather than a class on a wrapper that
no longer exists, and the sweep for leaked English words feeds each
label function three arguments now that the table is the whole grid's
and not one feature's.
Nine components were leaving as loose names beside `DataGrid` while
every other piece of chrome reached an application through `Grid.*`.
They are parts by the same definition - what you assemble when
`DataGrid` draws more, or less, than you want - so they join the
namespace and the nine top-level names go. `Grid.FormatPanel` is the
one that shortens: `Grid.ConditionalFormattingPanel` reads as a
sentence rather than as a part.

`GridStatus` becomes public. A feature sets `grid.status`, which is the
extension point the server model uses, and a type nobody can name is an
extension point nobody can implement.

Two smaller things in the code behind it. The server layer wrote its
status and cleared it from one effect, so the teardown ran on every
change it reacted to, blanking the status and rewriting it in the same
flush; clearing is its own effect now, with nothing to react to, and it
runs when the grid goes. And nine suites had each written the same cast
to get a context-reading part past `render`'s typing, which is one
export in `in-root.ts` now.
…ords

Three things, in one commit because they overlap in the same files and
splitting them would tear a feature in half: fourteen locale packs
carry both the punctuation sweep and the new operator's wording.

The typography guard comes over and now covers everything: `src`,
README and CHANGELOG. 412 characters across 102 files become ones a
person can type - 323 em dashes, 52 middle dots, 24 en dashes, an
ellipsis here and a curly quote there.

Two marks stay, and only inside a locale pack's strings. French marks
elision with a curly apostrophe, and `N'est pas vide` is bad French and
a broken string literal at once; Russian punctuates with an em dash
where English reaches for a colon. Their comments are still held to the
rule, and the en dash is not exempt anywhere: a page range reads `1-25
of 300` in all twelve languages now.

The vocabulary of two tiers goes with it. There is one grid, so nothing
should say otherwise: two flags named for the tiers become
`throughColumnFilter` and `throughBuilder`, the parity test is named
for what it compares, and every phrase that named a tier now names the
thing it actually meant. A reader in six
months would otherwise go looking for a package that was deleted.

`DateFilterOp` gains `notEqual`. The filter builder has offered it on
dates since it was written and the column filter never did, which is
what stood between the two implementations and being one. It keeps a
blank day the way `textPredicate` keeps a blank word, it has wording in
all twelve languages, and the server contract's reference
implementation learned it too - `server-contract-ops.test.ts`
enumerates the operators, so it failed the moment the list grew, which
is exactly what it is for.

The test fixtures move to `src/tests/fixtures/`, leaving 74 test files
where 74 test files and 8 components used to sit together.
The filter builder carried its own comparison engine and the worker
carried a third, all answering the same question and kept in step by a
parity test. There is one now: a condition is mapped to the shape a
column filter already speaks, and `valuePredicateFor` answers it.
`evaluate.ts` loses 119 lines and the worker's `predicates.ts` all 117.

Two things had to be settled first, and neither was the mechanical swap
the plan described.

The builder decided how to compare by looking at the cell. A column of
numeric strings therefore compared as numbers in one row and as text in
the next. The column filter is told the kind instead, and taking that
straight would have read an untyped `{ id: 'total' }` of real numbers as
text, which answers that 80 is greater than 100. So the kind is settled
once per column: from the declaration when there is one, and otherwise
from a sample of the rows the pass is filtering, read through the same
gate so a masked column is guessed from what the user can see.

The two engines also disagreed about a blank in a negative test, and
the disagreement was a bug rather than a choice: `textPredicate` kept a
blank on `notEqual` while `numberPredicate` dropped it on `neq`, from
one guard applied to every comparator at once. A cell with no number in
it is not the number being excluded. `neq` keeps it now, the server
contract's reference implementation says so too, and the parity test
that documented the difference documents the agreement.

Three smaller answers change with them. `in` maps to a set filter
whatever the column holds, because membership is not a comparison. A
set matches by the key its value list was built with, so 2 and '2' are
two entries rather than one. And an operator a column does not offer
filters nothing rather than falling through to text: a half-built
condition must not hide rows.
Every barrel in `src/lib` was checked by parsing import statements
rather than matching words, and by following the re-export chain up to
the public entries, so a name reachable by an application counts as
used and a name that merely exists elsewhere does not. Three earlier
passes had missed things for exactly those two reasons.

The feature id constants go. `SELECTION`, `EDITING`, `FILTERING`,
`ADVANCED_FILTER` and eighteen more are in no root barrel, so no
application can import one, and each is used in a single line inside
its own module: the `grid.feature(ID)` that `getSelection` and its
siblings already wrap. They stay exported from the file that defines
them, where the module's own code reads them.

Six types follow, on the rule that a type earns its place by appearing
in the signature of something public: `UndoState`, `UndoCommand` and
`Validated` are named only by private methods of `Editing`, and
`DropTarget`, `FilterDraft` and `FloatingCell` by no exported class at
all. `EditingCell`, `MoveDirection`, `ColumnDragState` and
`ToggleModifiers` stay by the same rule - each is a field or a
parameter of a class an application holds.

`components/cells/index.ts` is deleted outright: nothing has ever
imported it, and the five cell components are reached from the files
that draw them. `menus` keeps only the context menu; the column menu
and the filter panel are opened by the header from state the features
own, and mean nothing mounted alone. The xlsx barrel drops from
thirty-four names to nine, because `src/lib/xlsx.ts` already names the
public surface from the files, and a barrel repeating that decision is
a second copy nobody keeps in step.

One thing is added rather than removed. `getPolicy`, `getDataImport`
and `getShowValuesAs` are public while the types they hand back were
not, so an application could call them and not name what it got. The
other thirteen features export theirs; these three now do too.
… broke

Aggregation, auto columns, the combined grid, fill, both filter demos,
find, formatting, formulas, import, policy, ranges, sharing,
structures, views, the worker and xlsx. Roughly 4,600 lines of grids
that had not been drawn since the modules moved.

Drawing them found two things `svelte-check` cannot: it reads types, it
does not run effects.

Every demo writes `<GroupPanel {grid} />` beside `<DataGrid {grid} />`,
and a panel had stopped taking a grid. `DataGrid` owns its root, so
there was nowhere left to put a panel except by abandoning it and
assembling `Grid.Root` by hand - a step back from what these pages used
to do. A panel reads its grid from context and takes one when it is
handed one, which is what `getGridOrNull` in this codebase was already
there for, and the generic comes back with it so the grid stays typed.

The filter builder was handed a grid and its nested group still asked
context for one, so two demos threw on mount even with the panel fixed.
The labels are passed down now.

`ported-demos.svelte.test.ts` mounts all seventeen and asserts the one
thing worth asserting: a grid appeared. A page that throws on mount is
the cheapest bug to find and the easiest to never look for.
A date condition compared the wrong day. The target was written with
`toISOString`, which calls a `Date` at local midnight the day before
anywhere west of Greenwich, while the cell it is measured against is
read as a local day. An epoch number fared worse: `String(number)` is
not a date any parser reads, so the condition matched nothing. Both are
written from local parts now.

`matches` inferred a column's kind from the single row it was asked
about. The same row therefore answered differently depending on which
row it was, and a blank cell made its column text, which quietly turned
`gt` into a condition that filters nothing. It samples the grid's rows,
as a pass does.

That sample looked at the first fifty rows rather than the first fifty
values, so a column whose early rows are blank was called text for the
same reason. It walks up to a thousand rows for fifty values now.

Replacing without regard to case indexed the original string with
offsets taken from a lowercased copy. `toLowerCase` does not preserve
length - Turkish dotted I becomes two code units - so every offset past
one drifted and the replacement was written into the middle of a
character. It goes through a pattern.

And `min` and `max` walk their column instead of spreading it into an
argument list. The limit is an engine's rather than the language's, and
low enough on JavaScriptCore for a column of this size to reach; the
throw would land in the pipeline's `$derived` and cost the render pass
rather than one cell. It did not reproduce on Node or Chromium at two
million, so this one is a hazard removed rather than a bug observed.

Each has a test that fails without its fix.
Roughly three thousand lines of commentary go. What is left is what
shows up in an editor: a `/** */` block sitting on an export, and the
option types beside each feature. An implementation note in the middle
of a function was never visible to anyone using the package, and there
were far too many of them to read past.

`no-comments.test.ts` holds the line, and it judges by shape rather
than by folder: a doc block on an export is documentation, anything
else is not. A suppression stays, because `eslint-disable` and
`svelte-ignore` are instructions to a tool rather than notes to a
person. Four `catch` blocks lost their only content and became empty,
which is the idiom here - each has its fallback on the next line - so
`no-empty` allows one.

The documents catch up with the code in the same pass.

`CHANGELOG.md` describes this release by what it does. It names no
second package, because none was ever published, and telling everyone
that something they never had is gone explains nothing. One line in the
0.1.0 entry pointed at that package and now points at nothing instead.

`MIGRATING.md` is written for the people who actually have to act:
those on 1.x. Three things break - the label table grew, a blank passes
`neq` on a number, and the loading skeleton draws cells - and each has
the diff that fixes it.

`README.md` was still describing nine feature areas. Someone reading
the front door had no way to learn that sixteen modules exist. The
tables cover them now, and `component` joins the extension points.

The last traces go with them: the release script no longer tells anyone
to update a second repository, the issue template stops promising that
grouping and xlsx are somewhere else, a storage key drops a word from
its name, and three demos stop dressing an old product split as sample
data.
The server demo and the theming demo were the two names both packages
used, and they wanted opposite treatment.

They demo different things, so the server one becomes `/server-model`:
`/server` shows the raw `rowModel: 'server'` contract, and this shows
the feature built on it - paged, blocks fetched as the viewport moves,
and a group whose children are fetched when it opens. Merging them
would have cost one of the two.

Theming wanted the opposite. Once the two slot tables became one there
was nothing left for a second page to show, so the existing one gains a
section that shows the thing worth showing: `groupPanel` and
`rangeCell` take `ui` exactly as `cell` and `statusBar` do, with no
second configuration to remember.

Writing that section found a gap. A panel laid out beside a grid takes
the grid it was handed, but the overrides still travelled by context,
so it drew with none of them. `ui` belongs to the grid: the root writes
it to `grid.ui`, and a part that knows which grid it draws for now
knows which overrides apply. There is still one place to set it.

`rows`, `spans` and `stress` join the mount test. Nothing had ever
drawn them, and a page nobody opens is a page that can rot unseen.
Formula, worker and xlsx scale tests, and eighteen more ceilings for
grouping, ranges, the filter builder, conditional formatting and the
server model. Forty-six budgets now, all passing.

They arrive as `feature-budgets.test.ts` rather than merged into
`budgets.test.ts`, because a kernel budget and a feature budget answer
different questions and one file of seven hundred lines answers
neither well.

The two fixtures had the same three names and different rows, so
`BenchRow` becomes the superset and the extra columns live in
`wideBenchColumns`. `benchColumns` is untouched on purpose: the
ceilings already measured against it describe that grid, and widening
it would have quietly moved every one of them.

Two call sites needed the shapes they now work with: statistics read
nodes rather than rows since the comparison engines merged, and an
infinite-mode placeholder has to fill the wider row.

This is the last of it. Nothing is left to bring over.
The rule was too loose and I had applied it by hand. It allowed a doc
block on any export, but most exports are internal: 233 names reach an
application, and 72 of the 88 blocks kept last time sat on functions
nobody outside this package can import. Nobody was ever going to hover
`getGridTheme`.

`no-comments.test.ts` works the surface out for itself now, walking the
two entry points and following a star export into the file behind it,
so a block earns its place by sitting on a name that is actually
reachable. A doc on an internal export fails it, which I checked by
writing one.

Three places still spelled out the blank test the library has a
function for, one of them written during this merge and the other two
older than it.

`rows`, `spans` and `stress` had no mount test. They do now.

And `src/benchmarks` joins `scripts` in being allowed to print: a
budget exists to report a number, and eleven suppressions saying so
would be worse than one line of configuration.
…one shape

The audit I had been trusting matched by name. A name only had to
appear somewhere in the repo to count as used, and barrels re-export
names that consumers import straight from the file behind them, so
every dead line looked alive. It reported nothing left to do.

Following the specifier instead, and resolving `./x.js` to `x.ts` and
then `x/index.ts`, finds 100 barrel lines nobody imports across fifteen
files. `components/internal` offered twenty-six names and twenty-three
of them went nowhere: everything reaches `context.js`, `theme.js` and
`window.js` directly. `core/utils` was still offering the four
formatters that `public-api.test.ts` exists to keep private, and their
own test imports them from `format.js`.

A second pass covers symbols carrying `export` that only their own file
uses: 109 of them, twenty-five being the feature id constants. Four
more were referenced nowhere at all, including a props interface whose
`rowMode` field does not exist anywhere else in the package.

`GroupToggle` had to keep its export. `ColumnModel.groupToggles` is
public and infers its type across a module boundary, so declaration
emit cannot name it otherwise. `npm run check` says nothing about this;
only the build does, which is worth remembering next time.

Three structural things came out of the same read. A file in
`components` did nothing but forward `core/theme`, and the variants
module re-exported two types it had imported, so the theme had three
doors and one room; it has one door now. `server-row-model` reached
across into `core` to resell `isLoadingRow`, which is a row node
predicate and now sits beside `isDataRow` where it belongs, and could
not sit in both places because the root stars core and features both
and a name from two stars is ambiguous. Two feature classes carried a
`State` suffix without the collision that earns one: eighteen others
export plain, and `CommandPalette`, `FindReplace` and `SavedViews` take
the suffix because a component owns those names. Neither shipped in
1.3.1, so there is nothing to migrate.

The shape every barrel now holds: re-exports and nothing else, one
statement per source module, statements ordered by specifier with an
external package first, values before types and each run alphabetical.
`core/index.ts` had been naming `./grid/index.js` three times and
`./interaction/index.js` twice.

Comparing the public barrels against HEAD, the only names that moved
are those two renames.
Five conditions, one per rule the barrels now follow: re-exports and
nothing else, each source module named once, statements ordered by
specifier and specifiers by name, and no name offered that the library
never imports.

The last one is the one worth having. It turns today's hundred dead
lines into a rule, so adding a barrel entry nobody needs fails rather
than accumulating until somebody thinks to look.

It works the public surface out for itself, walking the star exports
from the three entry points, because `core`, `components` and
`features` are barrels no internal file imports and a naive reading
calls all three dead. The first version of the test did exactly that,
which is how the walk earned its place.
Eighteen routes arrived with the new modules and not one of them had a
link. The home page still listed the twenty that shipped with 1.3.1, so
the only way into the new ones was to type the path.

They get their own framed block above the existing list, ordered by
what they demonstrate rather than alphabetically: grouping and totals
first, then formulas and filtering, ranges, the wizard and views and
masking, and the server, worker and workbook at the end with the route
that runs eight modules at once. Each carries the one thing worth
looking at, because a link that only says its own name asks the reader
to open it to find out.
A grid of a million rows paginates to twenty thousand pages, and the
footer drew them as `199971999819999` with the current page clipped
inside its background. The buttons are square by size, and five digits
need 34px in a box of 32, so the text spilled out of one button and
across the next. Measured on the failing test before the fix: 32<34,
32<35, 32<35, 32<35.

The button now keeps its height and its 2rem minimum but grows with
what it holds.

Getting there needs `size-auto` rather than the `w-auto` that reads
more naturally, because tailwind-merge here does not treat `size-*` as
conflicting with `w-*`: both survive the merge and which one wins comes
down to the order the utilities happen to be generated in. `size-auto`
displaces the class outright, so the result does not depend on that.

Three tests hold it: five digits do not overflow, two page numbers keep
a gap between them, and a one digit page is still square, which is the
footer nearly every grid shows.
Three features write a result onto the row keyed by column id: `grouping()`
its aggregates, `showValuesAs()` its shares, `formula()` its calculated
columns. `getCellValue` ran the accessor first and unconditionally, so a
column defined with one never saw any of them and read its own field back
instead.

What that looked like: three views of one salary column, aggregated as sum,
median and 90th percentile, all showing the sum. A percent column reading
57,209,100% rather than 19.1%, which looks like a formatting bug and is not.
A calculated column showing what it was calculated from.

It made an ordinary layout impossible rather than merely awkward. Column ids
share one namespace, so a second view of one field has to carry an accessor to
hold a distinct id, and an accessor is exactly what switched the feature off.

The fix is not to prefer an own property over the accessor whenever the row
carries one, which would break every accessor that derives from a field of the
same name. A feature says which columns it computed, `withComputed` records
those ids beside the values, and `getCellValue` steps around the accessor for
those and no others. The lookup sits inside the `if (column.accessor)` branch,
so a column without one reads exactly as before.
A grid of sixty rows in five groups reported `65 of 60 rows`. The filtered
number skipped only `meta.fullWidth` nodes, which covers a detail panel and
nothing else, so a group header, a group footer and the grand total were
counted in the numerator while the total counted data rows alone. A filtered
count larger than the total is not a rounding difference; it reads as a broken
component, and it is on screen at first paint with no interaction.

It counts with `isDataRow` now, which is the predicate the kernel already
exports for this question.

The total was wrong in a second way. It read `grid.sourceNodes.length`, and
under `tree({ getChildren })` only the roots are in `data`, so a seventeen-row
tree reported a total of two. `Tree` answers `totalRows` for its own shape,
walking the hierarchy in a `$derived` so it costs a pass when the data changes
and nothing when a row is expanded or collapsed. The flat shape keeps reading
the source length, because with `getParentId` every row is already there.
The filter builder offered a date column `gt`, `gte`, `lt` and `lte` with a
number field asking for epoch milliseconds, rather than `before` and `after`
with a date picker. `inferKind` tested number before date, and `numericOrNull`
accepts a `Date` because a `Date` coerces to its epoch, so a column of real
`Date` objects never reached the date branch. A column that declares
`type: 'date'` was never affected, which is why this only shows on a column
that declares nothing.

Moving the date branch above the number branch would have been worse than the
bug: `toDate(2020)` is a valid `Date`, so every column of plain numbers would
have become a date column. A `Date` gets its own test at the top instead, and
the rest of the order is untouched. `[2020, 2021, 2022]` is still a number
column, and there is a test that says so.
`notanumber` in a `type: 'number'` column drew an empty cell. The value was in
the row and the editor opened on it; only the screen said nothing was there.
The import wizard is where this costs the most, because staging rows in the
grid so a bad cell can be fixed where it sits is the argument the feature is
built on, and a hundred staged rows meant opening every marked cell one at a
time to find out what the file had said.

The formatter declines now rather than answering the empty string, and every
caller already falls back to the raw text. A blank cell is untouched: `null`,
`undefined` and `''` still draw the column's `emptyText`, which is checked
before this and is a different question.

It reaches masking too, and settles an inconsistency there. A `policy()` rule
substituting a string into a typed column used to blank the cell while the
same rule on an untyped column drew the mark. Both draw it now, and the test
that pinned the old behaviour asserts the new one, keeping the assertion that
matters: the real value is still nowhere in the DOM.
`GridFeature.component` is the extension point for the work a feature can only
do from inside the render tree, and its own doc comment told you to reach the
root element with `getGridElement`. No barrel offered it. Neither helper could
be imported, and the component is handed no props, so the one hook added for
"a listener on the grid's own element" could not reach the element or the grid.

`getGridContext` had been on the list of names deliberately kept back as
wiring. That was right while only this package's own parts read the context;
the `component` hook is what changed it, and the README already states the
rule this falls under: needing something unexported in order to build a
feature is a gap in the extension points.

Both are exported with the doc a reader sees on hover. `setGridContext` and
`setGridElement` stay in, because the grid is the only thing that should be
answering either question.
`percentOfParent` divides a column by the same column on the row above it,
which on a grouped grid is the group row. A group row holds a number only for
a column `grouping()` aggregates, so a column without an aggregation has no
denominator and renders blank down its whole length, with nothing thrown and
nothing logged. A blank column reads as a data problem rather than a
configuration one, which is the expensive part.

It warns once per column now, naming the column and the line to add, the way
`grouping()` and `conditionalFormatting()` already report what a row model
costs them. The `ShowAs` doc comment says it as well, since that is where a
reader hovering the option looks first.
Ten public names had no prose anywhere: `aggregate`, `totalsKindOf`,
`isDataRow`, `isLoadingRow`, `isDetailNode`, `localStorageViews`,
`ShareTooLongError`, `FormulaError`, `isFormulaError` and `FUNCTION_NAMES`,
with `autoColumns` in the changelog but not the README. These are not the
`getX(grid)` accessors, which one paragraph covers as a family. They are names
an application has to import and call: catch `ShareTooLongError` around a share
link, pass `localStorageViews` to `savedViews`, ask `isLoadingRow` inside a
cell snippet. The README gains a table for them and two worked examples, and
`getGridContext` and `getGridElement` join it, since a feature's `component` is
handed no props and those are how it reaches the grid and its root.

The Fixed section had been mixing two different things. Only `gateReader` was
about code 1.3.1 ships; the rest describe modules that arrive in this release,
which a reader deciding whether to upgrade cannot separate. It is split now,
with the footer and the typed column joining the first group and the six
defects found while writing the documentation joining the second, along with
three from the filter builder that had never been written down at all.

One of those six changes what an application sees, so it earns a section in
MIGRATING rather than a line: a typed column now draws text it cannot parse
where it used to draw nothing. A test asserting the empty string will fail,
which is what happened to this package's own.

Also: the release adds eighteen demo routes, not seventeen. The other two
counts in that line, 147 labels and 66 slots, check out.
The root entry has had its exact export list pinned since the API froze. The
second entry never did: `public-api.test.ts` reads only `$lib/index.js`, and
`bundle-shape.test.ts` measures tree-shaking rather than surface, so all
twenty-nine names behind `@sv5ui/datagrid/xlsx` could be added or dropped and
no check would say a word.

Two conditions close it. The first reads `src/lib/xlsx.ts` and compares every
name it offers against the list, which is the only way to see the sixteen that
are types and leave no trace at runtime. The second imports the entry and
checks the thirteen values really resolve, so a re-export pointing at a name
that moved fails here rather than in an application.

Proved by breaking it: dropping `StyleTable` from the entry turns the first
condition red on that name alone.
The cell coerced whatever it held. `Boolean('false')` is true, so the string
`false` drew a green tick carrying `aria-label="true"` - the exact opposite of
the data, said out loud to a screen reader. `no` did the same, and so did any
non-empty word: `maybe` was indistinguishable from a genuine `true` except by
a red ring the reader cannot hear.

It was reported as a regression from the typed-column fix. It is not. `boolean`
has never been in `TEXT_TYPES`, so that branch never reached the formatter, and
`GridCellValue.svelte` is byte-identical to 1.3.1 apart from two comments. The
cell has lied for as long as the type has existed.

The tick is now drawn only for a value that reads as one, and everything else
draws its own text. `toBoolean` moves to `core/utils` for that, with the words
the import wizard already knew in twelve languages, and `parseBoolean` calls
it rather than keeping a second copy. The wizard could not simply be imported
from: it sits a layer above the kernel.

The suggested fix would not have worked. `formatCellText` answers `undefined`
for every value on a boolean column, `true` included, so falling back to it
would have restored the blank cell it was meant to replace.
`<DataGrid class="h-40" />` painted its rows 1,483px below its own box, over
whatever came next on the page, and did not scroll. The class was routed by
whether the grid was virtual: to the viewport when it was, to the root when it
was not. The root stacks the toolbar, the grid and the footer and has no
overflow of its own, so a height landed on the one element of the two that
cannot contain anything.

The viewport takes it in both cases now. It is the box a reader sees - it
carries the border and the corners - and routing by feature meant registering
`virtualization()` silently moved where a caller's class went, which is a
worse property than either destination on its own.

This is not new in 2.0: 1.3.1 splits the class the same way.

The test measures what actually goes wrong rather than a number: that the
viewport does not paint below the root, and that it scrolls, on both kinds of
grid. Reverting the routing turns it red.
The handle was reported as previewing without committing. It does commit: a
drag down three rows carries 10, 20 into 30, 40, 50. The report was driven
through synthesized pointer events, which do not reproduce pointer capture,
and its author said as much.

Dismissing it left nothing behind, which is the part worth fixing. The only
fill-handle write this suite covered was a drag across the columns, so the
vertical gesture - the one the feature is sold on - rested on a throwaway test
that no longer exists. It has one of its own now.
Both fixes land in code 1.3.1 already ships, so they join that group in the
changelog rather than the one for modules this release adds. The group is five
entries now: the footer, `gateReader`, the typed column, the boolean column
and the height.

The README gains the answer to a question it had been raising and leaving:
`FUNCTION_NAMES` is offered for building an expression editor, and the next
thing such an editor needs is to check what the user typed. There is a public
path - write it with `set` and read `errorOf`, which answers the message and
the offset - and it was not written down, so the docs reached for the parser
instead. The parser stays in: it answers an internal tree that would have to
hold still for as long as the package does.
…open

A grouped grid with every group shut reported `0 of 60 rows`, printed under
five group rows that between them account for all sixty. `N of M` is the string
the grid uses for a filter, and there was no filter on the grid. A tree read
`7 of 17` collapsed and `17 rows` open, so which of the two spellings appeared
was decided by how much of the hierarchy happened to be unfolded.

This one is mine. `61ee979` moved the count onto `isDataRow` over the drawn
list, which correctly stopped counting group headers, footers and the grand
total. What it did not separate is the other reason a data row is missing from
that list: it is inside something shut. Before that commit the same question
answered `5 of 60`, wrong in a quieter way.

Both features fold rows at `PIPELINE_ORDER.group`, so the count moves one stage
earlier, to a new `grid.filteredNodes`. A nested tree needs more than that,
because only its roots are in `data` at that point, so `Tree` answers for its
own shape and both its counts now share one walk. The pinned-row correction
comes out: `pinSplit` runs after the new stage, so adding it back would have
counted those rows twice.

Nine tests, covering both tree shapes and a grouped grid shut, open, filtered
and pinned. Reverting the count turns seven of them red, which is how I found
that the first pair I wrote proved nothing: `toContain('60 rows')` is satisfied
by the string `0 of 60 rows`, and the helper behind it was reading the whole
grid rather than the bar.
The announcement after a filter read `grid.totalRows`, which is the length of
the drawn list. On a grouped grid that counts a group header, a footer and the
grand total as rows, so the bar said `6 of 24` while the live region said
`9 rows` about the same grid at the same moment. It also moved when a group was
folded: a reader closing a disclosure was told the result set had changed from
nine to two.

This is the defect `61ee979` and `5de8b5b` fixed on the status bar, still alive
on the path nobody looks at. Both read `grid.filteredRowCount` now.

`totalRows` keeps its name and its other six callers. Keyboard bounds,
`aria-rowcount` and the body height all want the drawn list, and a group header
is a row you can focus. The name is what hid this, so the README now spells out
which count answers which question.

The first version of the fix left the same disagreement alive in the narrower
case. A nested tree keeps its children on the row rather than in `data`, so the
pipeline sees only roots at that stage, and the announcer - which lives in
`core` and cannot import a feature - would have said `1 row` where the bar said
`3`. `GridFeature` gains a `rowCount` hook: a feature that decided the shape of
the list answers for it, and the grid counts for itself when none does. That
also takes the tree special case back out of `GridStatusBar`, which had no
business knowing about it.

Five tests. Reverting the announcer's source turns all five red, including the
open-versus-folded one, which in its first form proved nothing because the
collapse it asked for never took effect.
Stepping through matches did everything except the part that lets anyone see
it. The counter advanced, the highlight moved and the current match took the
stronger mark, while a match 682px down a 384px box stayed where it was.

`#reveal` asks `grid.api.ensureVisible`, and `virtualization()` is the only
feature that registers it, so on a plain grid the optional call was a no-op.
The `focusCell` beside it moves the roving focus, which is state rather than
scroll. A paged grid was already handled by the branch above; the ordinary
scrolling grid is the one that fell through.

The viewport supplies `ensureVisible` when no virtualizer does. It is an
explicit request to reveal a row, so it stays out of the way of the effect that
follows the roving focus - that one is guarded on focus being inside the grid,
which is right, and is also why it could not have carried this: the caller is
in the find panel at the time.

Two things in the first draft were wrong and are worth naming. A string target
was resolved against `grid.nodes`, the windowed list, where the numeric one and
`virtualization`'s own lookup both mean an index into `preWindowNodes`. And a
microtask is not enough after `followPage` changes the page, so the DOM is
awaited with `tick()` before the cell is looked for.

Three tests, one per kind of grid. Removing the fallback reds exactly the
plain-grid one, which is the shape a fix that quietly changed the other two
would not have had.
A detail panel draws one cell across every column and declared
`aria-colindex="1"` and nothing else. ARIA reads a missing `aria-colspan` as a
span of one, which is the single value that cannot be right here, so a screen
reader on a panel over five columns was told column 1 of 5, with nothing to say
the other four were not there.

It was reported on `masterDetail()`, and the same markup is used twice more:
the row that says there is no data and the row that reports a failed fetch.
Both carry a cell across every column and both had the same gap, so all three
are fixed together.

The span counts the visible columns, so it follows a column being hidden the
way `aria-colcount` already did, and it is emitted only above one, matching
what a spanning data cell and a spanning header cell have always done - both of
those had been declaring theirs all along, which is what makes this a gap
rather than a decision.

Four tests. Removing the attribute reds all four. The axe suite does not cover
this and could not: whether `aria-colspan` matches what CSS spans is a relation
between style and semantics rather than a rule about the markup.
…apshot

Two defects in the same feature, both reached through server-side grouping.

A hierarchy the server sends was drawn inside `role="grid"`. `tree()`,
`grouping()` and `masterDetail()` each turn expansion on, which is what decides
between `grid` and `treegrid`, and `serverRowModel({ getRowMeta })` did not. So
rows carrying `aria-level` and `aria-expanded` sat in a container where those
attributes are undefined, and a screen reader was told five flat rows where
there were five groups holding five thousand people. Expansion is turned on now
when `getRowMeta` or a non-empty `groupBy` says there is a hierarchy; a flat
server grid still reports `grid`, and there is a test that says so.

Applying a saved view asked the server once per slice it hydrated. `setState`
hydrates each feature in turn, and sort and filter each announced themselves, so
a view carrying both cost two requests where one header click costs one - and
the answer to the first was on screen, sorted but not yet filtered, until the
second arrived. The event-driven refetch coalesces to the end of the tick.
`refreshServerRows()` still fetches immediately, because an explicit call is
not a state change to be folded into others.

The measurement in the report said three requests rather than two; two is what
this grid produces with a sort and a filter, and the shape is the same either
way: one per slice.
…nswers it

`serverRowModel()` puts the tree on every request as `request.advancedFilter`,
and a backend that reads it filters correctly. While that was happening,
`isApplied` returned `false` unconditionally in server mode and the console
said the feature does not filter here, closing with advice to read the model
and send it - which the server row model had already done, unasked. The panel
was meanwhile displaying its own "the server handles this" hint, so the grid
contradicted itself in two places at once.

Both now account for who is sending the tree. `isApplied` is true in server
mode when a server row model is registered, and the warning is skipped in that
case. Without one the warning stands and `isApplied` stays false, because then
it really is the application that has to send it, and the advice is right.

The check is by feature id rather than an import: `server-row-model` already
imports `getAdvancedFilter` to build the request, so importing back would close
a cycle.

The warning is reworded to say the grid does not filter locally rather than
that nothing does, and to name `request.advancedFilter` so a reader can look
for it. One existing assertion matched on the old wording; it matches the new
one. Its failure also left a spy installed and made the next test in the file
look like a regression it was not.
All three land in modules this release adds, so they join that group rather
than the one for code 1.3.1 already ships.

Not written down, because it turned out not to exist: the report also said the
`getRowMeta` doc comment carries an example returning `{ group: true,
childCount }`, which would not compile against `RowMeta`. There is no such
example in the source, in the README, or in the built types the docs are
installed against.
Two holes the server-mode audit found and the release plan said to close
before 2.0.0 rather than ship as known limits.

`showValuesAs()` divided by the rows the client held. Measured on a server grid
of forty rows at ten each: with the first block in, row one read 10%; with all
four blocks in, the same row read 2.5%. The number a user saw depended on how
far they had scrolled, and nothing said so. `conditionalFormatting()` already
handles exactly this class for its rank rules - skip, warn once, list what was
skipped - and `showValuesAs()` now does the same for `percentOfGrandTotal` and
`percentOfParent`. `percentOfRow` keeps running, because a row divided by its
own columns needs nothing the client lacks.

`selectAll()` on the same grid selected forty rows of which thirty were
placeholders standing in for blocks not yet fetched, and `getSelectedRows()`
handed all thirty back as empty objects. `selectableNodes` filtered out a
full-width row and whatever `isRowSelectable` refused, and not a row that had
not arrived. It does now. The header checkbox and Ctrl+A both go through it.

Four tests. Reverting either fix reds its tests; the first attempt to prove
that for the selection fix proved nothing, because prettier had reformatted
the line and the revert never matched.
@ndlabdev
ndlabdev merged commit be3ae93 into dev Sep 11, 2026
1 check 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