feat: the sixteen modules, nine parts and the xlsx entry, for 2.0.0 - #47
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Forty-three commits carrying everything
## [Unreleased]describes. Sixteenfeature modules, nine parts to draw them with, a second entry point at
@sv5ui/datagrid/xlsx, and the fixes found while the documentation was beingwritten against them. Every module is opt-in the way the nine before were, and
bundle-shape.test.tsbuilds a real entry to prove that a feature nobodyregistered is code nobody bundles.
The version is not bumped here.
npm run release -- majordoes that fromdevonce this lands, and semver asks for
2.0.0: two entries underChangedarebreaking 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
neqona 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 thediff that fixes it.
Checks
npm run lint,npm run check: clean, 0 errors.npm run release:verify: tarball publishable, 530 files, no test files,every export resolves,
GridApiaugmentations survive in 21 files.The full account of each change is in CHANGELOG.md.