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 `