diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 0f7828d..f4af49e 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -8,12 +8,10 @@ body:
value: |
Thanks for the suggestion. Please search existing issues first.
- Row grouping, a tree data model, master and detail rows, range selection,
- aggregation and xlsx export are deliberately not in this package; they are
- planned for a separate pro package. The kernel already renders nested rows as a
- `treegrid`, and infinite scroll is a shape this package supports today —
- `rowModel: 'server'` with `virtualization()`, appending as the window nears the
- end of what is loaded. See `/server/infinite` in the playground.
+ Row grouping, tree data, master and detail rows, range selection,
+ aggregation and xlsx export all ship here. Register the feature module
+ you need and check the playground route for it before opening an issue:
+ a request that is already answered costs you a wait and us a reply.
- type: textarea
id: problem
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4d3e453..f067456 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,249 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.0.0] - 2026-09-11
+
+The largest release the grid has had. Sixteen feature modules, nine parts to
+draw them with, and a second entry point for writing spreadsheets. Everything
+here is opt-in the way the nine before it were: a feature you do not register
+is code your bundle never sees, and there is now a test that builds a real
+entry to prove it.
+
+[Upgrading from 1.x](MIGRATING.md) is three behaviour changes and one type; the
+rest is additive.
+
+### Added
+
+- **Grouping.** `grouping()` groups by any number of columns and aggregates
+ with thirteen functions, including `percentile` and `weightedAvg`. Group
+ footers, a grand total, and a `Grid.GroupPanel` to drive it.
+- **Show values as.** `showValuesAs()` reads a number as a share of the grand
+ total, of its parent group, or of its row. It replaces the value, so sorting,
+ copying and exporting all agree with what is on screen.
+- **Tree data and master/detail.** `tree()` takes either nested children or a
+ parent id, and lifts an orphan to the root rather than dropping it.
+ `masterDetail()` opens a panel under any row.
+- **Calculated columns.** `formula()` compiles an expression to a tree and
+ walks it - no `eval` - with 31 functions and a dependency graph that refuses
+ a cycle. The expression is a string, so it survives a saved view and a link.
+- **A filter builder.** `advancedFilter()` holds a nested `(A AND B) OR C`,
+ with the operators each column type can answer, and narrows what the column
+ filters already narrowed rather than replacing them. `Grid.FilterBuilder`
+ draws it.
+- **Cell ranges.** `rangeSelection()` brings Excel-style ranges: several at
+ once with Ctrl, a fill handle that reads a series out of numbers, dates or
+ text, writing a whole range with `Ctrl+Enter`, cut and move, a clipboard that
+ carries both plain text and HTML, and `Grid.RangeStatusBar`.
+- **Find and replace.** `findReplace()` searches the rows a filter left,
+ honours case and whole-cell, and counts what it can write apart from what it
+ found.
+- **Conditional formatting.** `conditionalFormatting()` paints colour scales,
+ data bars, duplicates, top N and expression rules. Colours mix through
+ `color-mix`, so a theme token works, and the rules export into a workbook as
+ Excel's own.
+- **An import wizard.** `dataImport()` reads CSV, TSV, XLSX and the clipboard,
+ guesses types across locales, remembers a column mapping per grid, validates
+ through each column's schema, and stages the rows in the grid itself so they
+ can be fixed in place before they are committed.
+- **Saved views and shareable links.** `savedViews()` keeps named views in
+ `localStorage` or storage of your own, and packs a whole grid state into a
+ URL.
+- **Value masking.** `policy()` masks a column, a row or a cell with `hide`,
+ `redact`, `last4`, `email`, `initials` or a function of your own, and applies
+ it at render, export, clipboard, search, facet and edit.
+- **A server row model.** `serverRowModel()` fetches in pages or in blocks as
+ the viewport moves, with placeholders and a `fetchAll` for exporting the
+ whole set.
+- **A worker row model.** `workerDataSource()` loads a columnar copy into a
+ worker, filters and sorts on indices, and falls back to the main thread for a
+ column a worker cannot see.
+- **A command palette.** `commandPalette()` gathers what the registered
+ features offer behind `Ctrl/Cmd+K`.
+- **XLSX, without a dependency.** `@sv5ui/datagrid/xlsx` writes a real workbook:
+ several sheets, styles, Excel's own conditional formatting rules, formula
+ cells, and 200k rows for 26 MB of heap because it writes in chunks.
+- **`GridFeature.component`**, a component the grid mounts inside its root, for
+ the work a feature can only do from inside the render tree: an effect, a
+ listener on the grid's element, a layer over the rows. Nothing imports it, so
+ a feature nobody registered is a component nobody bundles.
+- **`GridState.status`**, which a feature that owns the rows sets so the body
+ can draw a fetch in progress without naming the feature.
+- `notEqual` on a date column, in `DateFilterOp` and in all twelve languages.
+- `autoColumns`, which reads a `ColumnDef[]` off the data.
+- `getGridContext` and `getGridElement` are exported, because
+ `GridFeature.component` is mounted inside the grid and had no way to reach
+ either the grid or its root element from outside the package. The doc comment
+ on `component` named `getGridElement` while no barrel offered it.
+- The named exports a feature brings besides its factory, each one documented
+ in the README: `aggregate` and `totalsKindOf` with `grouping()`,
+ `isDetailNode` with `masterDetail()`, `isLoadingRow` with `serverRowModel()`,
+ `localStorageViews` and `ShareTooLongError` with `savedViews()`,
+ `FormulaError`, `isFormulaError` and `FUNCTION_NAMES` with `formula()`, and
+ `isDataRow` from the kernel. They are what your own code reaches for when it
+ has to read back a row the grid drew, catch what a feature throws, or reuse a
+ calculation it made.
+- 147 more label keys (67 to 214) and 66 more slots (73 to 139), in all twelve
+ languages, and eighteen demo routes.
+
+### Changed
+
+- **Breaking.** `DataGridLabels` gains 147 required members. An application
+ passing its own complete table as `mergeLabels`' base no longer compiles;
+ `DataGridLabelsInput`, which is what an application normally hands the grid,
+ is unaffected.
+- **Breaking.** `neq` on a number column now keeps a blank cell, the way
+ `notEqual` on a text column always has. A cell with no number in it is not
+ the number being excluded. The two disagreed because one guard was applied to
+ every numeric comparator at once.
+- A row of skeletons drawn while loading now gives its cells `role="gridcell"`.
+ A `row` owning none is a row a screen reader cannot read. A test counting
+ gridcells during a load will see them now.
+- The footer's page range reads `1-25 of 300` in every language. It was an en
+ dash, which is not a character a keyboard has.
+
+### Fixed
+
+Five of these are in code 1.3.1 already ships, so they land whether or not any
+of the new modules interests you.
+
+- The footer no longer runs one page number into the next. Its buttons were
+ square at 2rem and five digits need more, so a grid of twenty thousand pages
+ drew `199971999819999` with the current page clipped inside its own
+ background. A button keeps its height and its 2rem minimum and now grows
+ with what it holds.
+- `gateReader` composes a column's readers once per pass rather than once per
+ cell: 15,000 compositions became three over 5,000 rows and three columns.
+- A boolean column no longer claims a value it does not have. The cell coerced
+ whatever it held, so the strings `false` and `no` drew a green tick carrying
+ `aria-label="true"`, the exact opposite of the data, and a screen reader was
+ told so. Any non-empty word did the same. The tick is drawn only for a value
+ that reads as a boolean now - a real one, a number, or a word the grid knows
+ in twelve languages - and anything else draws its own text instead.
+- A height given to `` holds the rows inside it on a
+ grid that has not registered `virtualization()`. The class was routed to the
+ root, which stacks the toolbar, the grid and the footer and has no overflow
+ of its own, while the element that scrolls is the viewport inside it.
+ Measured at `h-40`: the rows painted 1,483px below the box and over whatever
+ followed on the page. The viewport takes the class in both cases now, so
+ registering a feature no longer moves where your class lands.
+- A typed column no longer swallows text it cannot parse. `notanumber` in a
+ `type: 'number'` column, or `1234-56-78` in a `type: 'date'` one, drew an
+ empty cell: the value was in the row, the editor opened on it, and the screen
+ said nothing was there. The formatter now declines rather than answering the
+ empty string, and the cell falls back to the raw text. A blank cell is still
+ blank, and still draws `emptyText`.
+
+The rest are defects found in the modules this release adds. No grid running
+1.3.1 met them, and they are written down because somebody deciding whether to
+trust new code deserves to see what it has been held to.
+
+- A server grid no longer says "no data" while its first request is still out.
+- A snapshot carrying a `Date` survives a share link. The canonical form
+ rebuilt every object from its entries, which turns a `Date` into `{}`, so a
+ link and `localStorage` disagreed about the same slice.
+- `autoColumns` no longer reads a column of `1234-56-78` part numbers as dates,
+ which typed the column `date` and then drew every cell blank.
+- Aggregating `min` or `max` walks the column instead of spreading it into an
+ argument list, which has an engine limit a grid this size can reach.
+- Replacing without regard to case no longer corrupts text around a character
+ that changes length when lowercased.
+- A date condition in the filter builder compared the wrong day. The target was
+ written with `toISOString`, which names 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: it was stringified into something no date parser
+ reads, so the condition matched nothing. Both are written from local parts.
+- The filter builder inferred a column's kind from the single row it was asked
+ about, so the same condition answered differently depending on which row it
+ landed on, and a blank cell made its column text, which quietly turned `gt`
+ into a condition that filters nothing. It samples the grid's rows instead.
+- That sample read 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
+ now walks up to a thousand rows to find fifty values.
+- A column that reads its field through an `accessor` shows what a feature
+ computed for it. `grouping()`, `showValuesAs()` and `formula()` all write a
+ result onto the row keyed by column id, and the accessor ran first and
+ unconditionally, so the column read its own field back instead. Three views
+ of one salary column showed the same sum three times, a share read as the
+ number underneath it, and a calculated column showed what it was calculated
+ from. Since a second view of one field needs an `accessor` to hold a distinct
+ id, this made a common grouping layout impossible to express.
+- `showValuesAs()` no longer measures a share against the rows the client
+ happens to hold. On `rowModel: 'server'` a `percentOfGrandTotal` divided by
+ the loaded blocks, so one row read 10% with ten rows in and 2.5% with forty,
+ and moved as the user scrolled. The two whole-column shares are skipped there
+ now, with one warning and a `skippedColumns` list, the way
+ `conditionalFormatting()` already treats its rank rules; `percentOfRow` still
+ runs because it reads one row at a time.
+- Select all on a server grid selects the rows that have arrived and none of the
+ placeholders. With ten of forty rows loaded it selected forty, thirty of them
+ empty rows standing in for a block still on its way, and `getSelectedRows()`
+ handed all thirty back.
+- A hierarchy the server sends is drawn inside a `treegrid`. `tree()`,
+ `grouping()` and `masterDetail()` all turn expansion on, and
+ `serverRowModel({ getRowMeta })` did not, so rows carrying `aria-level` and
+ `aria-expanded` sat inside a plain `grid`, where those attributes are
+ undefined. A screen reader was told five flat rows where there were five
+ groups. A server grid with no hierarchy is unaffected.
+- Applying a saved view, a share link or any snapshot asks a server row model
+ once rather than once per slice it hydrates. `setState` hydrates each feature
+ in turn and each hydration announced itself, so a view carrying both a sort
+ and a filter cost two requests where a header click costs one, and the answer
+ to the first was drawn until the second arrived. The refetch coalesces to the
+ end of the tick; an explicit `refreshServerRows()` still fetches at once.
+- `advancedFilter()` no longer reports itself broken on a server that answers
+ it. `serverRowModel()` puts the tree on every request as
+ `request.advancedFilter`, and a backend reading it filters correctly, while
+ `isApplied` returned `false` and the console said the feature does not work
+ here. Both now account for the server: the warning is skipped when
+ `serverRowModel()` is registered, and reworded to say the grid does not
+ filter locally rather than that nothing does. Without a server row model the
+ warning stands, because then it is the application that has to send the tree.
+- A cell that covers the whole row says how many columns it covers. A detail
+ panel, the row that says there is no data and the row that reports a failed
+ fetch all draw one cell across every column, and all three declared
+ `aria-colindex="1"` and nothing else, which ARIA reads as a span of one. A
+ screen reader on a detail panel was told column 1 of 5. They carry
+ `aria-colspan` now, counted from the visible columns so it follows a column
+ being hidden the way `aria-colcount` already did. A spanning data cell and a
+ spanning header cell had been declaring theirs all along.
+- Find's next match brings the match into view on a grid that scrolls.
+ `ensureVisible` was registered by `virtualization()` alone, so on a plain
+ grid the call behind the panel's Next button did nothing: the counter
+ advanced, the highlight moved, and a match 682px down a 384px box stayed
+ where it was. A paged grid already turned to the right page; only the
+ ordinary scrolling grid fell through. The viewport supplies `ensureVisible`
+ when no virtualizer does, so the feature's primary gesture works on any grid.
+- A screen reader is told the same number the status bar shows. The
+ announcement after a filter read `grid.totalRows`, which is the drawn list, so
+ a grouped grid said `9 rows` where the bar said `6 of 24`: six data rows plus
+ a group header, a footer and the grand total. It also moved when a group was
+ folded, telling a reader the result set had changed because they closed a
+ disclosure. Both now read `grid.filteredRowCount`, and `GridFeature` gains a
+ `rowCount` hook so a feature holding rows off the pipeline, as a nested tree
+ does, answers for its own shape rather than being counted wrong.
+- The status bar counts the rows a filter left, not the rows that happen to be
+ open. It read from the drawn list, which does not hold a row inside a
+ collapsed group or tree node, so a grouped grid with everything shut reported
+ `0 of 60 rows` under five group rows accounting for all sixty, and a tree read
+ `7 of 17` collapsed and `17 rows` open. `N of M` is the string for a filter,
+ and there was no filter. It counts one stage earlier now, before anything
+ folds a row away, through the new `grid.filteredNodes`.
+- The status bar counts rows rather than the furniture around them. A group
+ header, a group footer and the grand total were counted in the filtered
+ number but not in the total, so a grid of sixty rows in five groups reported
+ `65 of 60 rows`. It now counts with `isDataRow`, and takes a nested tree's
+ total from the whole hierarchy instead of from the roots in `data`, which had
+ a seventeen-row tree reporting a total of two.
+- The filter builder reads a column of `Date` objects as dates. The number test
+ ran first and accepts a `Date`, because a `Date` coerces to its epoch
+ milliseconds, so the column was offered `gt` and `lt` with a number field
+ asking for a timestamp instead of `before` and `after` with a date picker. A
+ column of plain numbers is still a number column.
+- `showValuesAs({ percentOfParent })` says so once when the column has no
+ aggregation on a grouped grid, instead of drawing a blank column in silence.
+ The group row it divides by holds a number only for a column `grouping()`
+ aggregates, so without one there is no denominator.
+
## [1.3.1] - 2026-09-03
### Fixed
@@ -53,7 +296,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- `CellDecoration.style` — a feature decorating a cell can now write CSS
+- `CellDecoration.style` - a feature decorating a cell can now write CSS
declarations onto it, not only class names. A class can say _which_ of a
fixed set of looks a cell takes; it cannot say a value computed per cell,
which is what a colour scale, a data bar or a per-user cursor tint is. The
@@ -62,8 +305,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
several features decorating the same cell merge per property with the later
one winning, the way classes already concatenated.
- The grid writes its own layout — the grid column, the pinned offsets, the
- editor's padding — as style _directives_, which outrank the attribute a
+ The grid writes its own layout - the grid column, the pinned offsets, the
+ editor's padding - as style _directives_, which outrank the attribute a
decoration lands in. So a decoration cannot move a cell out of its column,
unpin it, or escape the row: it can only paint. A value is also cut at the
first `;`, so one entry stays one declaration and a colour read out of row
@@ -183,8 +426,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
rather than each caller remembering to.
`headerGroupCell` draws a group header the way `headerCell` draws a leaf
- one. The snippet is handed the group cell — id, label, span, whether it is
- folded — and a `toggle`, and the grid's own control stays beside what it
+ one. The snippet is handed the group cell - id, label, span, whether it is
+ folded - and a `toggle`, and the grid's own control stays beside what it
draws, so a badge or a count up there costs nothing. It draws into a box
of its own that shrinks and clips: a group is at its narrowest exactly
when it is folded, and what an app drew for the open state has to give way
@@ -445,7 +688,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a flat `find` over the defs it was handed, and a grid with header groups keeps
groups at the top level and the real columns in their `children`, so every
entry matched nothing and was dropped. A server-model grid built the way the
- README shows — `toSortRequest(getSorting(grid)!.sort, grid.columns.defs)` —
+ README shows - `toSortRequest(getSorting(grid)!.sort, grid.columns.defs)` -
sent an empty sort: the header arrow moved, `sortChanged` fired, the request
went out, and the rows came back in the order they left, with no error or
warning to say why.
@@ -455,7 +698,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
genuinely does not have is still dropped, as documented.
- `ui.headerCell` typography reaches a sortable column's label. The classes were
- on the cell — `uppercase` and `text-primary` both in its class list — but a
+ on the cell - `uppercase` and `text-primary` both in its class list - but a
sortable column wraps its label in the `