changelog
What changed, and what it means.
Last updated 20 August 2026 (v1.13.0)
Entries are written for the person deciding whether to upgrade: what changed, and what it means for a grid already in production.
[1.13.0] - 2026-08-31
The release that lets the grid drive a real query engine. A source can push the grid’s filter, sort and paging down to whatever holds the data, take back only what it asked for, and finish anything the engine could not do itself. The grid stays a client library: an adapter takes a connection, a URL or a token you already have and imports nothing, so the bundle is identical whether a page uses this or not.
Added
- Pushdown sources.
createPushdownSource({ adapter })turns the grid’s query into a portable request the adapter translates for the engine, with the split reported:source.lastPlan()says what reached the engine and what the grid finished locally. An adapter declares what it can do, filter (a term, a flat conjunction or a full condition tree), which operators, sort, quick search, windowing and totals, and everything it does not claim the grid does itself, so an adapter that declares nothing still works. - Four adapters.
duckdbAdapterfor DuckDB, including DuckDB-Wasm in the browser over Parquet with no server;odataAdapterfor an OData service;restAdapterfor the REST or GraphQL endpoint you already have; anddfqlAdapter. None of them ships an engine: each takes a connection, a URL or a token you provide. - Process-control charts.
control,movingRangeandcapabilitychart types draw an SPC study from a column’s declared tolerance, andgrid.statistics.capabilitygainsrules: 'nelson' | 'westernElectric'for the numbered rule breaks. Thirty-seven chart types now.
Changed
- No breaking changes. Existing grids and sources run unchanged.
[1.12.2] - 2026-08-31
A patch over 1.12.1. Chart error-bar whiskers now draw. The whisker was computed
and placed correctly but painted with no stroke, so it was there in the markup
and invisible on screen; it now carries the grid’s foreground colour, so
error: true shows the interval on the chart, not only in the DOM.
[1.12.1] - 2026-08-31
A patch over 1.12.0. A chart asked for error-bar whiskers it cannot compute, one bound to a summary or a derived panel rather than to the rows a figure was measured from, now says so once rather than drawing nothing in silence. The interval is computed from the readings the chart can see behind each mark.
[1.12.0] - 2026-08-31
A minor release with a new statistics figure, a complete type reference, and a web component that stands on its own. Existing grids are unaffected.
Added
- Confidence intervals.
grid.statistics.interval(col)returns the range an estimate pins a figure down to, rather than a single number: a mean with its margin of error, or a proportion withkind: 'proportion'and a rule for what counts as a success. Process capability can report one too. Each reads the filtered rows, so the interval narrows as the grid does and describes the population on screen rather than the whole table. - A complete type reference. The API reference gains a Type reference page: every interface the library declares, with the type of each member, generated from the type declarations so it always matches the release. It sits beside the hand-written namespace pages, which stay the place to start.
Changed
- The web component stands on its own. The
<lattice-grid>element now carries the grid’s own functions, so using the element and callingcreateGridare two separate choices rather than one setup shared between them. Use one or the other in a page, not both. - No breaking changes. A grid written for an earlier 1.x release runs unchanged.
[1.11.0] - 2026-08-31
A minor release that deepens derived grids in three ways: they can join a second grid, filter the grid they came from, and update in place when only a few rows change. Existing grids are unaffected, and a derived grid that uses none of it behaves exactly as before.
Added
- Joins in a derived grid. A derived source can match each row against a
second grid on a shared key and bring some of its fields across:
join: { with, on, select }. It runs before grouping, so a total or a condition can read a field the join produced, and it is an inner join by default, keeping the rows that matched, or a left join when the unmatched rows are the finding. - Cross-filtering, the path back up. Derivation runs one way, which is what
makes a chain of grids safe to reason about; cross-filtering is the single
deliberate way back. Set
crossFilter: trueon a derived grid and clicking a summary row filters the grid it summarises, through that grid’s own filter model, so it undoes, rides in a saved view, and appears in the filter UI already there, with no second filter model beside the real one. Thegrid.crossFilterhandle offersset,toggle,clearandget. - Row-level change identity. The
rows:changedevent now names exactly which rows were added, updated or removed. Anything that maintains state from a grid, a derived grid, a chart or a cache of your own, can patch just those rows rather than rebuild from scratch, so a live grid stays responsive as it streams.
Changed
- No breaking changes. A grid written for 1.10 or 1.9 runs unchanged; the additions are new configuration and a richer event payload, not a new contract.
[1.10.0] - 2026-08-30
The release that turns a grid into a dashboard. A grid can now take its rows from another grid and follow its filter, so a table and the panels beside it move together from a single control, and a matching tile sits above them. Existing grids are unaffected: every addition is opt-in, and a grid that uses none of it loads none of it.
Added
- Derived and chained grids. A grid can source its rows from another grid
rather than from data of its own:
source: { mode: 'derived', from }. Group and total the source, expand an array column into one row per element, round a date into a weekly or monthly series, keep the top few overall or the top few in every category, or transpose a column into a statistical profile. The result is a real grid - sortable, filterable, exportable, saveable into a view - that re-derives whenever the source is filtered, so the panels of a dashboard cannot disagree with the table they came from. A derived grid can itself be the source of another, so the chain composes to any depth and a single filter at its root moves every level at once. createStat, the dashboard tile. A label, a value, its movement against a baseline, and a line saying what the comparison was. It reads the grid rather than a copy of the data, so it cannot drift from the table beneath it, and it formats through the column’s own type, so a figure in hours reads in hours and money reads on its own ladder with nothing declared.showturns a maximum into the name of the row that holds it - the best rep rather than the best figure - andscope: 'all'holds a whole-set total steady while the filtered figures beside it move.
Changed
- No breaking changes. A grid written for 1.9 runs unchanged; the additions are new configuration, not a new contract.
[1.9.1] - 2026-08-30
A patch over 1.9.0, same API and same stylesheet, worth taking for anyone drawing charts.
Fixed
- Small multiples drew a single panel. A chart configured with
multiplesrendered one chart rather than one per distinct value of the column. It now draws the panel per group its own caption describes. - A numeric axis could order its ticks by first appearance rather than by
value, so an axis of bandwidths could read
100, 200, 500, 2000, 10000, 1000. Ticks now sort numerically. - A control-chart edge case on a constant series no longer divides by a zero moving range.
Changed
- Fourteen registered data types that shipped working but undeclared are now in
the TypeScript
TypeNameunion, so an editor completes and type-checks them.
[1.9.0] - 2026-08-29
The release that lets the grid answer back. Three things arrive together: a charts module, a statistics layer, and shadow columns. All three read the grid’s own filtered rows, so they say the same thing the grid says, and none of them is held back for a larger licence. Existing grids are unaffected: every addition is opt-in, and a grid that uses none of it loads none of it.
Added
- A charts module, thirty-five chart types drawn from the grid’s data.
import { createChart } from '@toclocoinc/lattice-grid/modules/charts', hand it a grid and a container, and it draws from the grid’s filtered rows and redraws when the grid moves, with nothing to subscribe to. The families run from the everyday (line, area, bar, scatter, pie, donut, histogram) through flow (sankey, chord, network), specialist (gauge, funnel, radar, candlestick) and statistical (Q-Q, ECDF, Lorenz, correlogram, control) to a geomap that joins ISO codes to continents or countries. The module imports nothing from the grid core, so it is a separate file a page loads only when it charts, and it takes no third-party charting dependency. - A statistics layer on
grid.statistics. Reductions in the totals row (median, percentiles, deviation and more than twenty others), a docked statistics panel that profiles a column and follows the filters, and the two-column and whole-series figures directly: correlation, regression, weighted averages and quantiles, volatility and drawdown. Every figure is computed over the filtered rows through the same handles the totals row uses, so a median in the panel and a median in the footer agree by definition. - Shadow columns. A column declared against another and maintained by the
grid, whose value depends on what happened before: delta and percent change
against the value at page load, a move count and a signed streak, rate of
change and a recent-history sparkline, and a positional family of rank,
percentile, quartile and places moved. They are real columns, sortable and
filterable, so “the top movers, most-changed first” is one sort rather than a
report.
grid.statistics.rebase(col)marks a new baseline. - Process capability. Declare a tolerance on a column with
spec: { lower, upper, target }and the capability figures, a control chart and any out-of-tolerance rule all read the same limits. - Data-driven conditional formatting. An
{ op: 'outlier' }rule that finds the extremes in the data rather than against a threshold you typed, and colour scales withfrom: 'quantile', which span a percentile range and are not flattened by a single large value. - Custom unit systems. Twenty-six unit families ship, and
registerUnitSystemadds your own from a set of symbols and their factors.
[1.7.1] - 2026-08-27
A scrolling fix. The distribution is otherwise identical to 1.7.0 (same API, same types, same stylesheet), so it is a drop-in upgrade, worth taking for any grid tall enough to reach the scaled part of the scroll range.
Fixed
- Dragging the scrollbar thumb could settle on the wrong rows in a very tall grid. Once a grid holds enough rows that its true pixel height runs past what a browser will lay out (on the order of a million), the scroll range is scaled to fit and a single thumb pixel then stands for many rows. In that range, dragging the thumb landed the view slightly adrift from where it was dropped, and the gap widened the further down the grid you went. The body now rides on its own explicitly sized surface, and scroll position is measured against that surface directly, so the thumb and the rows it brings into view stay in step at any height. Wheel and keyboard scrolling, and every grid below the scaling threshold, were never affected.
[1.7.0] - 2026-08-25
Added
-
Licence keys are perpetual by default. A key carries no expiry unless one is deliberately issued - a trial, a time-boxed pilot - so the ordinary key stays valid until the domains it names change, rather than lapsing on a date.
grid.licence.info().expiresisundefinedfor a perpetual key and an ISO date only for one issued with a term. -
Saved views can now persist with no backend.
views: { local: true }stores a user’s saved views in this browser’s ownlocalStorage, under a default key shared by every grid on the origin, or a key of your own viaviews: { local: { key: '…' } }to keep two grids’ views apart. This sits alongside the existingviews.storageoption, for a developer plugging in a real server - an explicitstoragealways wins if both are given, with a console warning, rather than the two silently competing. The adapter itself,createLocalViewStorage(opts), is exported directly too, for a custom key without thelocalshorthand or a differentStorage-shaped backing such assessionStorage. -
An htmx integration.
modules/htmxlets a grid survive htmx’s own DOM swaps, hydrate from a server-rendered<table>, and drive sort, filter and infinite scroll over plain htmx requests. It is a complete package rather than an add-on:createGrid,autoInit,hydrateTable,readTable,serialiseStateandrestoreStateare all re-exported alongside its own functions, so a page using htmx integration needs only this one import - never the base package as well. Importing it is enough for the lifecycle half on its own - it registers againstdocumentautomatically, building grids from[data-lattice-grid]elements onhtmx:loadand tearing them down onhtmx:beforeCleanupElementbefore htmx detaches the subtree they live in.driveServerMode(grid, trigger, opts)fires a request carryingoffset,limit,sortandfilterswhenever sort or filter changes, and replaces the grid’s rows with the response.driveInfiniteScroll(grid, sentinel, opts)appends rows as the grid’s own visible row window nears the end of what’s loaded - the sentinel’s ownhx-triggernames bothrevealed, for the first chunk, andlattice:scroll-near-end, for every chunk after, since a fixed-height virtualised grid’s own scroll area is what changes, not the page’s.driveOobUpdates(grid, opts)applies an out-of-band swap landing on[data-lattice-row="<key>"]to that row in place, leaving scroll position, selection and filter state untouched. A failed request never touches the grid’s existing rows and shows a recoverable message instead of leaving it blank.Browser back and forward restore the prior sort, filter and scroll position: on
htmx:beforeHistorySave, every live grid’s state is written onto its element for htmx’s own history snapshot to capture; onhtmx:historyRestore, it’s read back and applied, except on a cache miss, where a fresh server response is already the truth. Ships as ESM and as a plain<script src>build with no bundler required. See the developer guide’s “Using with htmx” section for the full wiring and the query convention a server needs to support. -
Declarative init, table hydration and compact state URLs, independent of any framework adapter.
autoInit(root)builds a grid on every[data-lattice-grid]element underroot, reading a sibling<script type="application/json" data-lattice-config">for its configuration, or hydrating a<table>element directly - reading its header row for columns and body rows for data, then replacing itself with the grid - when no config script is present.serialiseState(grid)/restoreState(grid, encoded)encode everythinggrid.statecovers as a compact, URL-safe string, diffed against the grid’s own defaults first, so an untouched grid encodes to a handful of characters. A<meta name="lattice-license" content="…">tag is read automatically when nolicenceis passed tocreateGrid. Every elementcreateGridbuilds on is now discoverable from itself:element.__latticeholds the live instance, cleared ondestroy(). -
A compatibility wrapper for dhtmlx Grid.
modules/dhtmlx-compatexposes aGridclass shaped like dhtmlx’s owndhx.Grid(Suite 5+) - the same constructor call, the same.data,.selection,.history,.exportand.eventsnamespaces - backed by a real Lattice grid underneath. For the covered surface, existing calling code does not change.Covers column definitions;
.data’sadd/update/remove/removeAll/parse/load/find/findAll/exists/getItem/getId/getIndex/getLength/forEach/serialize/sort/filter/resetFilter;.selection’ssetCell/getCell/getCells/isSelectedCell/removeCell;.history’sundo/redo/canUndo/canRedo/clear/getHistory;.export.csv/.xlsx; and a name-mapped subset of.events, several of which -cellClick,cellDblClick,cellRightClick,afterEditStart,afterEditEnd,afterSort- call your handler with dhtmlx’s own positional arguments rather than Lattice’s event object, matching dhtmlx’s documented signatures.afterRowDropis included, firing(data, event)from either a same-grid reorder settling or a row landing from another grid - dhtmlx has one event name for what Lattice models as two. Grid-leveldragItem: 'row'becomesrowReorder: true.Every
before*/can*/cancel*event is deliberately unmapped: dhtmlx lets a handler returnfalseto cancel the action, and Lattice has no cancelable-event model to honour that with. Row and column drag negotiation - a handler refusing or steering a drop mid-gesture - is unmapped for the same reason. Cross-grid dragging needs an explicitrowTransfer, passed straight through: dhtmlx lets any twodragItem: 'row'grids on a page exchange rows by default, and Lattice’srowTransferis deliberately opt-in per pair, with nothing to derive it from.export.pdf()andexport.png()throw - there is no raster export to translate to..rangeSelectionis offered on a best-effort basis, with a range shape of this wrapper’s own design rather than dhtmlx’s ownRangeSelectionmodule. The classic, pre-Suite-5dhtmlXGridObjectis not covered. See the developer guide’s “Coming from dhtmlx Grid” section for the full list.
Fixed
- A grid that had offloaded work to a background Worker left it running
after
destroy(). Every other listener, observer and buffer was released; the Worker itself was the one thing nothing reached. Only grids past the row-count threshold that triggers Worker offload were affected - a small or mid-sized grid was never at risk.destroy()now terminates it.
[1.6.2] - 2026-08-25
Fixed
- Dragging a row by its handle also started a range selection underneath it. The handle sits inside a cell, so a press on it was picked up by both gestures at once - the row began moving, and a one-cell range was left selected and stayed that way even after the row had already been dropped elsewhere. A press on the handle is now read as a row drag only.
Added
- Picking up a row now shows it, wherever the pointer goes. The row being dragged dims in its own grid, and a small label naming it follows the pointer for as long as the drag is held. A plain mouse drag holds no cursor across whatever it crosses, so previously nothing on screen showed a drag was under way until the pointer physically reached another grid.
[1.6.1] - 2026-08-23
Documentation only. The distribution is unchanged from 1.6.0 - same bundles, same types, same stylesheet - so an upgrade is worth taking only for the reference that ships beside them.
Fixed
state.applywas documented as returning nothing. It returns a report naming everything in a saved view it could not apply and why, which is how a host tells a user that a view saved against an older layout has aged. The behaviour has always been this; the reference was wrong.
Added
-
The reference now says what a saved view does when the columns change underneath it. A view is user data written against a column set that has since moved on, and nothing stated the rules.
A column added since the view was saved appears, in whatever state its definition declares, after the columns the view names - a view is not a whitelist, and silence about a column nobody had heard of is not an instruction to hide it. Ship a column
hiddenif it should not arrive announced. A removed column is skipped and reported, and a sort or grouping that pointed at it is dropped rather than left dangling.
[1.6.0] - 2026-08-22
Added
-
The grid’s own text can be translated. Every string the grid renders or announces now comes from a message catalogue rather than from the source, and a catalogue can be replaced or partially overridden through the new
messagesoption. British English (EN_GB) is the default, and catalogues ship for twenty other locales, each exported under the code shown: American English (EN_US), French (FR_FR,FR_CA), Italian (IT_IT), Spanish (ES_ES), Brazilian Portuguese (PT_BR), German (DE_DE), Dutch (NL_NL), Swedish (SV_SE), Danish (DA_DK), Norwegian (NB_NO), Finnish (FI_FI), Polish (PL_PL), Czech (CS_CZ), Hungarian (HU_HU), Romanian (RO_RO), Ukrainian (UK_UA), Greek (EL_GR), Japanese (JA_JP) and Arabic (AR).A catalogue carries a region only where two variants of the language ship, which is why the Englishes and the two Frenches are qualified and nothing else is. Arabic is therefore
AR- butAR_SAis exported as an alias for it, because that is the name people reach for first. Both are the same catalogue. For any other tag,resolveCatalogue('ar-EG')finds it.Each is an export of the package, so importing one does not reduce what is bundled.
import { createGrid, FR_FR } from '@toclocoinc/lattice-grid'; createGrid(element, { locale: 'fr-FR', messages: FR_FR });Where
localeis not given, the grid takes the language the page declares inlang. Overrides merge over the default, so translating part of the interface leaves the remainder in English rather than showing raw keys.MESSAGE_KEYSlists every key andauditCatalogue()reports what a catalogue is missing. -
Eleven strings that were never translatable now are. The comment panel’s labels and its Comment and Delete buttons, the timeline bar’s scrubber and Go live control, the fill handle’s tooltip, the icon picker’s list and the placeholder shown when a value is too long to encode as a QR code all read from the catalogue like everything else. They previously rendered in English whatever the locale.
-
grid.messagesexposes the resolved message set -t(),list(),number()and the activelocale- so a host-supplied renderer or panel can draw its text from the same source the grid does. -
Rows can be pinned above and below the scrolling body.
pinnedTopRowsandpinnedBottomRowshold rows against the header or the status bar, andgrid.setPinnedRows(rows, { edge })moves them at runtime. Use one for a column-units line, a target to compare against, a precomputed summary, or a note that must stay in view.createGrid(element, { columns, rows, pinnedTopRows: [{ product: 'Units', capacity: 'MW', margin: '%' }], });The rows render through the ordinary column pipeline - value getters, formatters and cell renderers all run - but they are not part of the data: not counted, sorted, filtered, grouped, selectable, totalled or exported. A filter that matches nothing leaves them visible.
-
The grid lays out right to left.
direction: 'rtl'renders the grid mirrored; omitted, the direction follows the element’s owndirand then the locale, solocale: 'ar'is right to left without further configuration. Pinned columns, the header, horizontal scrolling, column resize and reorder, the fill handle, annotations, the facet band and menu placement all follow the writing direction.createGrid(element, { locale: 'ar' }); // direction follows the locale createGrid(element, { direction: 'rtl' }); // or say so outright -
Fifty new data types for physical, engineering and scientific quantities. Each stores a plain number in a named base unit, so the column still backs onto a typed array and sorting, filtering, grouping and totalling stay ordinary arithmetic - only display and input are unit-aware.
{ field: 'span', type: 'metres' }, // 1500 → "1.5 km" { field: 'inlet', type: 'pressure' }, // 200000 → "200 kPa" { field: 'cap', type: 'capacitance' }, // 4.7e-11 → "47 pF" { field: 'intake', type: 'celsius' }, // accepts "72 F", stores 22.2Twelve new unit systems - speed, acceleration, area, volume, energy, power, force, pressure, torque, density, flow and angle - join the length, mass and elapsed-time ladders, which existed but had no declared type and needed a hand-rolled
dataTypesentry to reach. Fifteen SI-prefixed electrical and scientific quantities are generated from one prefix table: voltage, current, resistance, capacitance, inductance, charge, conductance, flux density, luminous flux, illuminance, substance, three radiation measures and frequency.registerUnitSystemadds a system of your own. The elapsed-time ladder now reaches down to nanoseconds.Ambiguous units are refused rather than guessed. A US gallon and an imperial gallon differ by about a fifth and “ton” means three different masses, so each carries its own symbol and the bare word is rejected on input. The same rule covers case:
mVandMVare a billion apart, so both work andmvdoes not.display: 'auto'walks the coherent SI ladder only. Customary units are accepted on input and available as an explicit display, but never chosen - otherwise 4,000 J renders as3.79 BTU, since auto picks the largest unit that fits. -
significantFiguresrenders to a fixed precision rather than a fixed number of decimals, so a column claims the same accuracy on every rung of its ladder. Rounding is applied before the unit is chosen: 999,999 bytes to three figures is1.00 MB, not1,000 kB. -
Angles average by direction. The mean of 359° and 1° is 0°; the arithmetic answer of 180° points the opposite way.
degreesandradianscolumns replace the mean and report nothing where the angles cancel. The sum stays arithmetic, since a total rotation of 720° is two turns. -
A temperature editor. A temperature column now gets its own editor rather than the plain number one, so the field carries its scale and
72 Fcan be typed straight into a Celsius column - which the type already accepted from a feed and from the clipboard, but not from the keyboard. -
Temperature columns refuse to be summed. Twenty degrees plus twenty degrees is not forty degrees.
celsius,fahrenheitandkelvinoffer average, minimum and maximum, convert on input, and are a separate type from the unit ladders because a temperature scale carries an offset that no single factor can express. -
Rows can be drawn as cards instead of columns.
rowTemplategives each row a layout of your own - a card list, a feed, a search-result list - over the same pipeline, so sorting, filtering, grouping, selection, permissions, redaction, saved views, undo, export and the remote source all keep working and only the drawing changes.createGrid(element, { columns, rows, rowKey: 'id', rowHeight: 64, rowTemplate: '<p class="title">{{data.name}}</p><p class="sub">{{data.owner}}</p>', });The template compiles once, the way a cell template does, and binds with
{{data.field}}. There is deliberately no per-row callback: that shape would be used to allocate DOM per row, and the virtualisation would stop paying for itself. Scrolling ten thousand records through a hundred pooled cards allocates nothing.{{cell.column}}shows what the table shows - the column’s own formatter, data type and lookup label - while{{data.field}}reads the raw value for a template that wants the number rather than the rendering. A card showing2where the table showsHeldis a bug, not a preference. Binding a raw field on a secret or redacted column reads its masked text instead, so a card cannot become the hole a redaction closes.cardsPerRowormaxCardWidthputs several cards on a line. The first is a count for a layout that must not reflow; the second is a ceiling, and the number across follows the container - 900px at a 260px ceiling is three cards that share the width, and a narrower container fits fewer. The scroll height counts lines rather than records, so the scrollbar matches the content.A card is still a row.
row:clickedandrow:dblclickedfire with the same payload as a table row, clicking selects, the context menu opens, androwReorderworks with the card itself as the drag handle. The presentation is announced as a list rather than a grid, since a card has no columns, and the column header is not drawn. -
A grid can collapse to cards when its container is narrow.
responsive: { maxWidth: 640, template }presents rows as cards below the threshold and as a table above it - the mobile answer, since a table on a phone is a compromise however it is styled.Measured on the container, not the viewport, so a grid in a narrow panel on a wide screen collapses and a grid filling a small tablet does not. The sort, the filters, the selection and the scroll position all survive the switch in both directions; an open cell editor is closed, since the cell it belonged to stops existing.
presentation:changedfires with'cards'or'table', and the change is announced.Sorting and filtering need a home when there are no column headings to click:
toolPanel: truekeeps its rail available in card presentation. Export is unaffected - the columns remain the data model, so a file from a collapsed grid holds every column, including ones the card does not show. -
A row can be edited on a form.
rowForm: trueand double-clicking a row opens it in a right-hand drawer - or a centred dialog withmode: 'dialog'- with one control per field, a Save and a Cancel. The fields are the grid’s own columns, edited with the same editors, types, formats and lookups the cells use.createGrid(element, { columns, rows, rowKey: 'id', editable: true, rowForm: true });Where the record holds more than the grid shows, a
loadfunction fetches the fuller version andfieldssays what to show and in what order. The panel opens immediately and fills in when the record arrives, rather than waiting with nothing on screen. A load that fails - or that has not answered withintimeoutmilliseconds, two seconds by default - leaves the panel open with a retry instead of closing and discarding the intent.rowForm: { mode: 'dialog', load: ({ key }) => fetch(`/api/orders/${key}`).then((r) => r.json()), fields: [{ field: 'name', label: 'Name' }, { field: 'ref', label: 'Reference' }], }Every editor is available on a form, including your own: a field named after a column borrows that column’s editor, and any field may name one with
editor, configured bytype,propsandlookupexactly as on a column. A picker - a date, a dropdown, a tree, a colour - shows its value on the field and opens its panel when clicked, rather than arriving open.Save writes the changed fields that map to columns and emits
form:savedwith all of them, including any that came fromloadand have no column; where the record is persisted is yours.grid.formopens, closes and saves from code, andtrigger: falseleaves opening entirely to it.containerbuilds the form in an element of your own - a sidebar, a panel below the grid, a column in a layout you already have - instead of over the grid. It takes an element, a selector or a function, and a selector is resolved when the form opens rather than when the grid is configured, since a grid is usually built before the layout around it exists. A form in your own container is announced as a region rather than a modal dialog and does not trap Tab, because it sits beside the grid rather than over it.On an editable grid the form takes the double click, so double-clicking a row opens the form rather than a cell editor. Cell editing stays on Enter and on typing into the cell.
-
Columns can be tagged, and filtered by tag. Give columns a
tagslist and setcolumnTagFilter: true, and a bar above the headings lets a user show only the columns carrying a chosen tag - sixty months of figures across five years become twelve by picking a year.{ field: 'jan24', title: '01/24', tags: ['2024', 'Q1'] }Only tagged columns are ever hidden. An account name or a total belongs to no year, so it stays visible whatever is selected - and you do not have to tag every column merely to keep it on screen.
A column may carry several tags, which gives a second axis for free: tag each month with its year and its quarter, and either can be picked.
columns.tags(),columns.showTagged()andcolumns.activeTags()are the same thing from code. -
showColumnFunctions: falseleaves each column heading as its label, with no sort, filter or menu control. They are not drawn rather than hidden, so the label has the whole cell - which is what a dense grid of narrow columns wants. Sorting, filtering and the menu stay reachable through the API, the keyboard and the tool panel. -
Grid lines, rounded corners, and percentage column widths.
gridLineschooses which rules are drawn between cells:'horizontal'(the default, and what the grid has always drawn),'vertical','both'or'none'. Vertical rules between body cells are new - the grid never drew them - so the default is unchanged and nothing moves on upgrade.cornerRadiusrounds the grid’s outer corners:truefor the theme’s own radius, a number for pixels, or a string used as written.A column width may now be a percentage -
layout: { width: '25%' }- which is a share of the whole grid. That is not the same asflex, which divides only the space left after the fixed columns. -
Group headings stay pinned while you scroll inside a group. Scroll a few hundred rows in and the rows on screen still say which group they belong to, instead of leaving you to scroll back up and check.
On by default, stacking at most two - each heading costs a row of viewport.
stickyGroupHeaders: falseturns it off and a number sets the cap.grid.rows.groupHeadings(index)returns the same answer directly, for a breadcrumb of your own. -
A data type can refuse a total that means nothing for it. Values that do not add up the way plain numbers do now say so, and the grid fails when the column is configured rather than rendering a confident wrong figure.
Four types use it.
decibelanddecibelAmplitudesum and average in the linear domain and convert back - 90 dB and 90 dB make 93 dB, not 180.ratioandpercentRateaverage by weight, using the column named intypeOptions.weight: a 100% conversion on two visits and a 1% conversion on ten thousand average to 1.02%, not 50.5%.{ field: 'conversion', type: 'percentRate', total: 'avg', typeOptions: { weight: 'visits' } }A type that declares nothing supports every aggregate, so no existing column changes. Custom types can declare
totals.supportedandtotals.implementto do the same. -
Grids can be kept column-aligned.
alignedGridslinks two or more grids so they read as one table split into sections - a summary band above a detail grid, or two datasets side by side under identical columns.const summary = createGrid(top, { columns, rows: totals }); const detail = createGrid(bottom, { columns, rows, alignedGrids: [summary] });Column widths, order, visibility, pinning and horizontal scroll are shared. Sort, filters, selection, grouping and the rows stay independent - sharing those would make one grid with extra steps rather than two aligned ones.
Also adds
grid.scroll.to({ top, left }). -
grid.scrollreaches a cell, and takes a row key.scroll.toCell(row, colId)moves both axes in one call, andtoRownow accepts a row key as well as a display index - a key is what a caller usually holds, and it follows its row through a sort where an index does not. -
Rows can be dragged between grids.
rowTransferlets one grid send rows to another - a catalogue beside a basket, an available list beside an assigned one.createGrid(left, { columns, rows, rowReorder: true, rowTransfer: { receive: false, group: 'order' } }); createGrid(right, { columns, rows, rowTransfer: { send: false, group: 'order' } });Off by default.
sendandreceiveare both on when the option is present, so a one-way drag is expressed by turning off the direction you do not want.mode: 'copy'leaves the row where it was, andgroupstops unrelated grids on a page accepting each other’s rows.The target adds before the source removes, so a refused transfer - a duplicate key, most likely - loses nothing.
row:sent,row:copiedandrow:receivedreport what happened. -
Rows can be reordered by dragging.
rowReorder: trueadds a drag handle to the first visible column, andAlt+Shift+arrows does the same without a pointer.grid.rows.move(key, to)is the same operation from code.createGrid(element, { columns, rows, rowReorder: true }); grid.on('row:moved', () => api.saveOrder(grid.rows.data()));The move reorders your data and tells you it happened; writing the new order somewhere permanent is yours, since only you know where it lives.
It is refused while a sort, filter or grouping is active, and says why rather than springing the row back: dropping between two visible rows says nothing about where it belongs among rows that are hidden or reordered.
-
Press
?for the keyboard shortcuts. The grid ships twenty-odd bindings - arrows to navigate, Ctrl with arrows for the ends, Enter to sort a heading, Alt with arrows to resize it, letters to group from the tool panel - and nothing in the product told anyone they existed.The overlay is generated from the bindings themselves rather than written out, so it cannot drift from what the grid actually does, and a build check fails if a binding is added with nowhere to show it. Every description is translated in every catalogue. Modifiers follow the platform: the grid reads Ctrl or Cmd, so a Mac is shown Cmd.
Escape closes it and focus returns exactly where it was.
shortcuts: falsesuppresses it for a host that wants?for itself. -
Rows can span every column.
fullWidthdraws matching rows as a single band across the whole grid instead of dividing them into columns - a section banner, an explanatory note, an empty-group message, a “load more” affordance.createGrid(element, { fullWidth: { when: (row) => row.data.kind === 'section', render: ({ data }) => data.title, }, });The band is drawn over the pinned regions as well as the centre, so it spans every column, and it holds still while the columns scroll underneath. A full-width row is still one of your data rows - counted, sorted, filtered and exported like any other; only its presentation changes. For a row that should not be part of the data, use
pinnedTopRows. -
The column menu takes your own items.
columnMenuacceptedtrueorfalse; it now also accepts(params, defaults) => items, the same formcontextMenuhas always taken. It covers both routes into a column’s menu - the header’s 3-dot button and a right-click on the heading - andparamscarries{ colId, column, grid }.columnMenu: (params, defaults) => { const month = params.column.def.context?.month; if (!month) return defaults; return [...defaults, { name: 'Select quarter', action: () => selectQuarter(month) }]; }As with
contextMenu, an empty array suppresses the menu and returning nothing leaves the defaults alone.
Fixed
-
Text a numeric cell cannot read is refused rather than accepted. A refused value keeps what was in the cell, marks it with the reason, leaves the editor open holding the typed text, and stays in the cell rather than moving on. Clearing a cell deliberately still works: a blank field and an unreadable one are different answers.
-
Counts are pluralised by the rules of the language, not by an English suffix. Where the grid said “1 row” or “12 rows” it applied an
n === 1test, which is wrong in most of the languages now shipping. French makes zero singular - “0 ligne”, not “0 lignes”. Polish, Czech and Ukrainian need four forms, so five rows and two rows take different words. Romanian inserts a word from twenty upwards: “2 rânduri” but “20 de rânduri”. Arabic has six categories including a dual for exactly two. Japanese and Hungarian have effectively one. Plural selection now uses the platform’s own rules. -
Numbers inside messages are formatted for the locale. Several counts were interpolated directly and rendered ungrouped - in announcements, and in the
jsontype’s summary of a large value, which now reads[5,000 items]rather than[5000 items]. -
Lists inside announcements are joined the way the language joins them, rather than always with a comma.
-
Numbers are read in the grid’s locale on every write path. Typing
1.234,56into a number filter or editor already worked; pasting it into a cell, or writing it through the value pipeline, now reads the same way.Group and decimal separators come from the locale, including Switzerland’s apostrophe and the narrow no-break space French uses. A grid with no
localebehaves exactly as before. The guards that refuse bare arithmetic and stray formulas are unchanged, so2*3is still refused rather than stored as 23.
[1.5.4] - 2026-08-20
Fixed
getVersion()is exported from the module. The reference has always said it is available there, “for when you have no grid to hand”, and it was not: it was imported into the entry point and never re-exported, so the unused import made it look as though it were.grid.getVersion()on an instance was unaffected.
[1.5.3] - 2026-08-20
Fixed
- The
lattice-grid.cssspecifier resolves. It pointed at the unminified stylesheet, which is not part of the published distribution, so importing it failed. Both it and/cssnow resolve to the stylesheet that ships.
Changed
-
Bundle sizes are no longer quoted in the documentation. A figure in prose is wrong from the next release onwards, and several were: the adapters were described as far smaller than they are.
-
The web component’s constraint is documented. It carries the grid inside it, which is what makes it a drop-in custom element. Loading it alongside
createGridin the same page therefore gives you two independent copies of the grid, each with its own registries - a renderer, editor or data type registered through one is invisible to the other, and nothing errors to say so. Use one route or the other; the reference and the README now say this.
[1.5.2] - 2026-08-20
Fixed
-
require()returns the library rather than an empty object. The package declares"type": "module", which makes every.jsfile in it an ES module - including the UMD build thatrequireresolved to. Node parsed the wrapper as ESM, wheremoduleis not defined, so the branch that assignsmodule.exportsnever ran and the call handed back an empty namespace.It did not throw, which is why it survived:
require('@toclocoinc/lattice-grid')succeeded and returned{}. The CommonJS entry now ships as.cjs, which opts out of the package type whatever it is, and the build refuses to produce a manifest whoserequireentry is a.jsfile.The
<script>tag path is unaffected and still loadslattice-grid.min.js; no manifest is involved there. -
requireresolves to a file that is published. It pointed at the unminified UMD build, which is not part of the public distribution.
[1.5.1] - 2026-08-20
Fixed
-
The framework adapters can be imported. They shipped in
modules/and were unreachable: a package’sexportsmap blocks every subpath it does not list, and the adapters were not listed.@toclocoinc/lattice-grid/modules/reactand its siblings now resolve, verified by installing the built package and importing each one. -
The package name matches what you install. The manifest was published under the repository’s unscoped name while the package itself is scoped, so every import example in the documentation named something that does not exist.
-
The README describes what actually ships. It opened by claiming no framework wrapper, in a distribution containing four of them, and never mentioned the adapters at all. It also listed unminified builds that are not published, and carried a trademark notice for marks that do not exist. The repository README and the published one are now generated from one source, so they cannot disagree again.
[1.5.0] - 2026-08-20
Added
-
targetSize: 'large'raises hit areas for touch. The grid meets the minimum target size on its own, but that minimum is a conformance floor rather than a comfortable size for a finger; both mobile platforms recommend nearer 44 pixels. This raises the targets and leaves the type alone, which is the distinction that matters - a touch user wants a bigger target, a low-vision user wants bigger text, and density is already the control for the second. The two combine.It applies by itself under a coarse pointer, because the person holding one is both who the criterion is for and the least likely to go hunting for a setting.
targetSize: 'default'opts out.Density alone does not do this: measured at every preset, it scales the header, the rows and the type while the affordances inside them stay exactly as they were - the menu button 24 pixels, the filter 16, the resize grip 10. A grid with nothing configured measures exactly as it did.
-
Grouping, values and pivot no longer need a pointer. They were expressed by dragging a column into a zone in the tool panel, so on a grid without that panel there was no route to them at all, and for a keyboard user there was none anywhere. With a column focused in the panel’s list,
G,VandPput it into row groups, values or pivot columns - and take it out again, because a binding that only adds leaves no way back. Each change is announced.The column menu now carries the same three, alongside the grouping item it already had, so the functions are discoverable rather than only memorable.
-
rows.forEachAll(fn)walks the data rather than the view.rows.forEachvisits what is on screen - filtered, sorted, grouped, collapsed rows left out - which is the right default and the wrong answer for a caller totalling a column, exporting, or reconciling against another system. There was no way past the filter short of reading your own source array back.grid.rows.forEachAll(row => { total += row.data.amount });Leaf rows only, in the order they arrived: group rows are a product of the current grouping and are not in the data, and the sort belongs to the filtered view. A remote or paged source holds the page it has fetched rather than the whole set, so it says so instead of quietly handing back the filtered rows.
-
The grid says what changed, not only that something did. The live region announced three things - sort, filter and selection - and everything else happened in silence. Collapsing a group moved a hundred rows out of view without a word, which is worst for the user who cannot see them go.
Now announced as well: grouping applied or cleared, a group expanded or collapsed with what remains visible, page changes, undo and redo naming the action they reversed, a paste with the number of cells written, and rows arriving or leaving on a live feed.
The feed is summarised on a two-second interval rather than announced per batch. A screen reader queues what it is given, so narrating a fast feed leaves the user listening to counts that stopped being true several seconds ago - worse than saying nothing. Changed values are deliberately not announced: they already show themselves, and “500 updated” every two seconds is the noise that makes someone switch the grid off. Holding the feed silences it entirely, which is the point of holding it.
-
An accessibility section, with the keyboard map published in full. The bindings existed in the build and were documented nowhere, so the ones the grid already had were undiscoverable. The reference now carries the complete map, what a screen reader is told, the colour and contrast position, and the known limits stated plainly rather than omitted.
The map is generated from the bindings the build ships and checked on every build, so it cannot drift. A keyboard reference that is wrong is worse than none: a user who tries a listed binding and gets nothing concludes the grid is broken rather than the page.
-
The accessibility checks run on every build, across every shape of grid. They existed and ran only when a developer opened the devtools panel, so nothing stopped a refactor undoing them. They now run in the test suite against the configurations that differ structurally - flat, grouped, tree, pinned at both ends, editing, paginated and with a tool panel - because a flat grid is the easy case and the one that never breaks.
The rules were extended to cover what a grid gets wrong: the role matching the data, hierarchy rows carrying their position, headings being focusable, dialogs declaring their modality, the live region existing, target sizes, and chrome staying inside the grid. Colour rules are skipped where a document has no computed styles rather than bringing the checker down with them.
-
Popups behave the way they describe themselves. The comment panel keeps Tab inside itself and returns focus to the cell that opened it - behaving modally - while declaring
aria-modal="false", which tells a screen reader the grid behind is still available when it is not. It now declares what it does.The filter popup had the opposite problem: a dialog with no way to dismiss it from the keyboard and no focus return, so a user who opened it lost their place.
Escapenow closes it and focus goes back to whatever opened it. It stays non-modal, which is correct for it - the trap and the declaration now agree in both directions. -
The status strip no longer pushes its controls off the grid. It was a flex row that could not wrap and had nothing to scroll it, so on a narrow grid its last children simply ended up outside: at 320 pixels the next-page and last-page buttons sat 8 and 36 pixels beyond the right edge and could not be reached by any means. That is loss of functionality under WCAG 1.4.10 Reflow, not an untidy strip. The strip and the pager now wrap. Nothing changes above about 480 pixels, where they never overflowed.
-
Every control meets the minimum target size. WCAG 2.5.8 asks for 24 × 24, or 24 pixels of clearance, or the same function on a control that qualifies. Measured across all three densities, four controls met none of the three.
The page buttons were 19 × 19 with 22 pixels between centres, failing on size and clearance together; they now carry a 24-pixel floor that holds even when a host’s font size would otherwise shrink them.
In a column heading, the menu button is now 24 × 24 and stops clear of the resize grip, which is absolutely positioned over the cell’s edge and had been taking two pixels of it. That one change settles the heading: the grip gains the clearance it needed and passes on spacing, and the filter icon passes on equivalence, because filtering is a menu item and the menu button now conforms. Only the hit area grew - the glyph is unchanged until you hover it.
The cost is about eighteen pixels of heading furniture per resizable column, which is noticeable on a narrow column at spacious density, where the title has least room to give.
-
The tool panel’s column list is operable from the keyboard. Its rows reordered by dragging a grip, and the panel had no key handling at all - so the place a user goes to rearrange columns in bulk could be read and not changed without a pointer. The rows are now focusable, arrows walk the list, and
Shiftwith an arrow moves a column, matching the header’s binding so there is one gesture to learn rather than one per surface. Each move is announced, and a column already at an end says so rather than going quiet. -
The column menu can move and size a column. Reordering and resizing were expressible only as a pointer drag, so neither was discoverable and neither worked on a touch device, where there is no hover to reveal the resize grip. The menu now carries Move left, Move right, Move to start, Move to end, and a Width submenu of Narrower, Wider and Fit to content - the same steps the key bindings use, so the two routes agree rather than disagreeing by pixels.
Actions that cannot apply are disabled rather than hidden, so the menu keeps its shape from column to column and the greying is itself the explanation.
-
A refused edit says so, not just shows so. An optimistic write the provider rejects is rolled back and the cell marked for a couple of seconds. That marker was purely visual, so a screen reader user committed an edit, heard nothing, and had no way to learn the value had gone back - the highest consequence silence in the product, because the user believes they saved something they did not.
The rollback is now announced with the column, the value restored and the reason the provider gave. A batch that is refused wholesale produces one message with a count rather than one per cell, and an accepted edit stays silent, which is correct for the ordinary case.
-
history:appliedreports an undo or a redo.history:changedfires whenever the undo and redo stacks move, including when a new action is pushed onto them, so it cannot tell a host that something was actually reversed. The new event carries the direction and the step. -
Rows in a hierarchy report their position among their siblings. A tree or grouped row now carries
aria-posinsetandaria-setsize. Virtualising means most of a branch is not in the document, so a screen reader could not count the set and said nothing about position at all - “expanded, level 2” with no indication of whether that was the first of three or the last of four hundred. A flat grid writes neither, since the row index already answers it. -
A read-only grid says so.
aria-readonlyis set when editing is off, so the grid is distinguishable from an editable one before a user tries to edit it and nothing happens. -
Forced colours: the opt-out no longer leaks into a whole row.
forced-color-adjustis an inherited property, and it was set on selected cells - so everything inside a selected row silently opted out of forced colours and kept its own palette. A status pill in a selected row held its own pale background and its own text colour against the system’s selection ground, and the row’s high-contrast text colour never reached the text at all.Selection now opts out at the cell, so the system’s selection colours reach its text, and opts the decorations inside it back in, so a pill is forced like anything else. Both halves are needed: without the opt-out the browser paints the cell’s children on a plain background and the text disappears; with it alone, everything inside keeps its own palette.
Elsewhere the opt-out is gone. It is only ever needed to preserve an author colour, and it now appears on two leaf elements: a colour swatch, where the colour is the value, and the dot identifying a collaborator. A peer’s cursor and range are marked with a border instead, because those are cells with text inside them.
Progress and histogram fills were losing their forced-colours treatment to more specific variant rules while still carrying the opt-out - the worst of both, a bar keeping an author colour with the browser told not to correct it. They now match at the specificity of the rules they override.
-
Windows High Contrast Mode is supported. The grid had no
forced-colorshandling at all, so in that mode every piece of meaning it carries in a background tint was simply lost: which row was selected, which cell had changed, which rows were added or removed, where a pinned region ended, what a status pill signified.State is now translated rather than recoloured. Selection takes the system’s own selection colours. Pinned regions swap their shadow, which the mode does not render, for a rule. Pills, fill decorations, progress tracks and histogram bars each gain a border, since a fill with no edge disappears once its colour is discarded. Diff states stop relying on hue and are told apart by border style - solid for added, dashed for removed, doubled for changed - because the mode offers no way to hold four colours apart.
A colour swatch and a collaborator’s presence colour keep their own colour, declared explicitly: there the colour is the value, and translating it would destroy the meaning rather than carry it.
-
The column header is operable from the keyboard.
Ctrl+Alt+Hmoved focus onto a heading and nothing there responded to a key: sorting, the column menu, resizing and reordering were all bound to pointer events only, so a keyboard user who reached the header was stranded. With a heading focused:Keys ←→Move between headings Ctrl+←→First / last heading EnterorSpaceSort by the column, Shiftto add to the sortAlt+←→Resize the column, Ctrlfor a coarse stepShift+←→Move the column Alt+↓Open the column menu ↓orEscapeReturn focus to the data Resizing and moving announce what they did, and a resize that has reached the column’s
minormaxsays so rather than appearing to do nothing. The modifiers follow the convention already established elsewhere in the category, so the bindings are the ones most users will already have.
Fixed
-
A rejected value is associated with the reason for it. Editors set
aria-invalid, which says a value was refused and not why, and the message was written only as atitle- a tooltip, not a reliable accessible description.aria-errormessagenow points at the text in every case, so a screen reader reads what is wrong rather than only that something is. Where an editor already displayed the fault, that element is referenced rather than a second one being built. -
The grid’s role follows its configuration. The root role was read once, when the grid mounted. A grid given tree data afterwards went on describing itself as a flat
gridwhile rendering a hierarchy, leaving its rows carryingaria-expandedandaria-levelwith no role for them to belong to. It is now re-read whenever the configuration changes. -
A moved column reorders the headings, not only the data. Moving a column reordered the leaf order the body is built from and left the retained column group tree, which the header is built from, exactly as it was. The headings were then repositioned individually, so the header looked correct while the document still held the old order.
That gap is invisible on screen and decisive for a screen reader, which reads the document rather than the pixels: the heading order no longer matched the data beneath it. It also affected the order headings are reached in when moving between them by keyboard. Both descriptions of the order are now kept in step, on a plain grid and with grouped, generated and pinned columns present.
-
A moved column lands where it was dropped on a grouped grid. The drop target was resolved to a position counted across the visible columns and then applied to the full column order, which are the same list only when nothing is grouped, hidden or generated. With a row-grouped column or a selection checkbox present, a dragged column landed one or more places away from where it was released, and further away the more columns were grouped.
[1.4.0] - 2026-08-17
Added
-
grid.diff.swap()shows the snapshot as the data. A snapshot is held as plain objects and never enters the columnar store, which is why a removed row can be displayed but not sorted or filtered among the live ones. Swapping is the answer to that rather than teaching the pipeline to work across two data sets: the old rows become the real rows, with the whole pipeline behind them, and what was live becomes the comparison.grid.diff.swap(); // now looking at yesterday, compared against today grid.diff.swapped; // true grid.diff.swap(); // and backBoth sets are already in memory, so this costs one ingest of each - real work on a large grid, and a deliberate action rather than something to put behind a toggle that fires per keystroke. The comparison reverses: what was an addition is now a removal. That is what looking at the change from the other end means, so
swappedreports which way round the grid is anddiff:swappedannounces it. -
diff.removedRowsshows what was deleted. An audit view that reports additions and edits and silently omits deletions is telling half the story, and a removed row still exists in the snapshot even though it is gone from the data. One option decides both questions it raises:diff: { snapshot: yesterday, removedRows: 'pinned' }'pinned'shows it beneath the rows, struck through and dimmed, outside the row set - not counted byrows.count(), not exported, not selectable.'data'appends it to the set instead, so it is counted and exported. Omitted, nothing changes: it stays out, as it always has.Neither mode sorts or filters a removed row among the live ones - its values are the snapshot’s, and ordering yesterday’s numbers among today’s would present two data sets as one. Neither lets it be edited, since there is nothing left to write to; a write aimed at one is refused rather than reported as applied.
-
pivot.groupTotalsadds a total beside the pivoted columns. Pivoting by Country turns one Sales column into one per country; this adds the column that was there before the pivot took it apart - sales across all of them.'before'places the group at the near edge,'after'at the far edge, andtotalsLabelheads it. Omitted, a pivot has exactly the columns it always had.pivot: { groupTotals: 'after' }The option was declared and documented in 1.4.0 and did nothing; it is now implemented. It reads the reduction the group row already carries rather than computing a second one, and its columns count towards
maxColumns. -
The status bar reports unresolved comments on hidden rows.
comments.hiddenUnresolved()has always been able to answer the question and had nowhere to say it, so the only sign of an outstanding thread was a marker on a cell - and a filter that hides the row hides the marker with it. Acommentspanel now carries the count:1,204 of 20,000 rows 3 unresolved comments on hidden rowsIt is in the default panel set and silent whenever the count is zero, so a grid with nothing outstanding is unchanged. It stays silent while the comment index is partial, matching the count itself, which reports zero rather than a total it cannot stand behind.
Fixed
-
A pinned-end column no longer splits in two when the columns do not fill the grid. Its header sat against the right edge of the viewport while its cells stopped just after the last centre column, so one pinned column appeared as two: a stranded strip of values, and a heading far to the right of them. The regions now share an edge at every width.
A pinned region holds the viewport edge only while there is something to scroll. Where the columns do not fill the grid, nothing does, so the pinned columns sit directly after the centre ones and the spare width falls beyond them all - space at the end of the table rather than a band closed on both sides, which read as an empty column.
It showed up wherever the columns are narrower than the grid - fixed widths, or a
flexcolumn that has reached itsmax- and grew worse as the window widened. Grids whose columns already fill the width were never affected and are unchanged. -
layout.minWidthcorrected tolayout.minin the reference. The option has always beenmin; the sizing example named a key the grid does not read, so a flex column copied from it had no minimum and said nothing about it. -
An attribute selector no longer throws where
CSSis absent. Opening a comment thread read theCSSglobal through a guard that could not guard it: testing an undeclared name throws before the test is reached. Anywhere the global is missing, opening a thread raised aReferenceErrorfrom the event handler. The check is nowtypeof-based, and the fallback escapes the row key rather than passing it through, so a key containing a quote resolves to its cell instead of a broken selector. -
A column with no
typenow takes one from the data. Type inference was built and reachable through the column model, and nothing in the grid ever called it, so an undeclared column resolved totextwhatever it held. A column of numbers arrived left-aligned with a text filter and a text editor, and exported as text. Sampling now runs before the store is laid out, so the inferred type reaches storage as well as display:columns: [{ field: 'quantity' }] // number: right-aligned, numeric filterOnly undeclared columns are touched - an explicit
type, apreset,columnDefaultsand alookupall win, andtype: falsekeeps sampling off. Inference runs once, when rows first arrive, so a grid built empty and filled after a fetch infers on that first load and a later load cannot re-type a column underneath a formatter configured around it.Check columns you left undeclared. A column that was text by accident and suited you that way now becomes what its data says; declare
type: 'text', ortype: false, to keep it as it was. Sorting is unaffected either way: the default comparator is value-aware, so an undeclared numeric column already sorted 2.5 before 10. -
sampleSizereaches inference. The option was accepted and never passed on, so every grid sampled the default hundred values per column. A grid whose columns change character further down the data can now say so:sampleSize: 500 -
A template binding that resolves to nothing says so. A bare field name -
{{ amount }}rather than{{ data.amount }}- compiled without complaint and rendered empty for every row. The compiler had always collected these and nothing reported them. It now names the binding and the column, and stays quiet for a realcell.propsbinding, which lands in the same set. -
A rail action with a function title shows its text. On a text rail the button was built with neither text nor icon - an empty box that still worked when pressed - because a function title was skipped when the label was written and only ever re-read into the tooltip.
-
capture({ download: true })saves without a file name. It required afileNameas well, so asking for a download and not naming the file returned the blob and wrote nothing, which reads as the capture having failed. A name is generated when none is given, as the other exporters do. -
The trial watermark honours
prefers-reduced-motion. Its move between corners is a timer rather than a CSS animation, so the stylesheet’s media query could not reach it. It now stays in one corner for a reader who has asked for less movement. -
setSpotlightsays when nothing will happen. The spotlight is only painted while a presentation is running, so arming one on a stopped grid changed nothing and reported nothing. It now says so, and points out that stepping to a view clears the spotlight - so a deck should be started before one is armed, not after. -
config.formulaFunctionsreaches the evaluator. A registered=VAT(price)was refused exactly as an unknown name is, because neither the edit path’s parameters nor the value pipeline’s carried the registered functions through to the formula. The closed list still closes: registering one name does not open anything else. -
A formula can be typed into a number column. The number editor read its box as a number before the column’s own parser saw it, so
=1+1emptied the cell and=quantity*2silently stored2. Formula entry needed a text editor to be reachable at all. Text that starts as a formula now passes through untouched; anything else parses as a number exactly as before. -
Naming an editor without enabling editing says so.
edit: { editor: 'text' }leaves a column read-only - editing is opt-in and stays that way, since inferring it would make one line ofcolumnDefaultsturn every column editable - but the combination now warns instead of quietly doing nothing. -
The formula helpers are in the bundles.
evaluateFormula,parseFormula,referencesOf,looksLikeFormula,FormulaErrorandFUNCTIONSwere declared as top-level exports and absent from every build, because the package barrel the bundles are made from did not carry them. -
Type declarations corrected.
toolPanel.actionsdeclared four rail action names where nine ship, and theRailActiontype for supplying your own was declared and never referenced - both are now in the signature.grid.elementis documented as what it is: the element you passed tocreateGrid, not the grid’s own.latticeroot, which it builds inside - soclosest('.lattice')never matches it and a theme attribute set on it does nothing.toolPanel.panelslistsformattingalongside the other four. -
Every kind of body row says what it is. A group heading, a group footer and an inline grand total were all bare
lat-rowwith identical cell classes, so none could be themed or told apart - the only discriminator was the shape of the row key. The pinned grand total was the one exception, reachable through the sticky container, which meant the same logical row was styleable pinned and unstyleable inline. Rows now carrylat-row--group,lat-row--group-footer,lat-row--grand-totalandlat-row--detail, with a light default treatment you can override. -
Pinned columns that leave no room say so. Pinning more width than the grid has left the unpinned columns rendering at their minimum widths beneath the pinned regions - present in the DOM, invisible at every scroll position, and silent. The grid now names the widths involved and how many columns are affected.
-
Setting both
autoHeightand arowHeightfunction says which wins.autoHeightdid, silently, while every row still carried the height the function returned - so the function appeared to work while the geometry ignored it. -
autoHeightmeasures rows again. Two faults, and either alone was enough to make it do nothing. Cells werewhite-space: nowrapwith an ellipsis, so text never wrapped and no row could ever want more than one line. And the measurement read the row’s own rectangle - a height written from the height model moments earlier - so it returned the number it had just written, the batch was always empty, and no row was ever patched. Rows now wrap in auto mode, the content is measured from the cells, and the pass runs after the cells exist rather than before. Verified in a browser: wrapping rows resolve to 54px against 24px for single-line rows, tiling contiguously.autoHeightas “the grid takes the height of its rows rather than its container” was never affected and is unchanged. -
A formula cannot read a column the permissions model withholds. Typing
=salaryinto another cell returned the value even wheresalarywaswriteOnly- documented as never shown, exported, copied or searched - orhidden. Formula entry was a read channel those levels did not cover, and it needed no developer tools to use. A reference to such a column is now refused and the formula reports it, rather than resolving to a blank and computing a plausible number from it.A column you have merely hidden from view is unaffected: hiding a column is not a permission, and formulas may still reference it.
-
A pinned row no longer hides the last row of data. With
grandTotalRow: 'bottom'- and now withdiff.removedRows: 'pinned'- the strip is drawn over the body rather than beside it, and the body reserved no room for it. At full scroll the final row sat underneath: visible for a moment in an overscroll bounce, and unreachable otherwise, because there was nowhere further to scroll. The body now stops short by exactly the strip’s height. -
diff.strictNullgives one answer. With it set, the row-level and cell-level questions could disagree about the same cell:statusOfandchangedColumnssaid changed whilecellStatusandisChangedsaid unchanged, in three of the eight ways a value can move betweennull,undefinedand an absent field. The row comparison read the data as you supplied it on both sides; the cell comparison read your snapshot against a value fetched through the store, which normalises an absent value tonull- so the distinction the option exists to make survived on one side only. Both now read the same way, computed columns included. Grids withoutstrictNullwere never affected, since both spellings of absence are equal there by definition. -
The grid announces what it does. It carried no live region, so a screen reader was told nothing when the grid sorted, filtered or changed its selection - the three changes a sighted user sees immediately.
aria-sortandaria-rowcountdescribe the grid to someone who goes looking and say nothing at the moment it changes. A polite live region now announces “Sorted by Amount descending”, “Filtered to 3 rows of 12 rows”, “2 rows selected” and their cleared counterparts. Repeating an identical message is suppressed, since a reader hearing the same count twice has been told it changed. -
columnMenu: falseremoves the menu button. The menu itself was correctly suppressed, but each heading still drew the button that opens it, so every column kept a control that did nothing when pressed. -
A live feed no longer slows down as the grid grows. Applying an update scanned the whole row array to find the row it named - twice, when the row arrived as a patch object rather than the stored one, which is what a feed sends. The cost of one update therefore rose with the size of the grid: 1.1ms on a hundred thousand rows, 4.7ms on four hundred thousand. It is now a key lookup and flat at about 0.01ms whatever the grid holds.
Queuing had a second, compounding fault: each queued message rebuilt the entire pending batch, so a window holding nineteen thousand rows made every further message a nineteen-thousand-row copy and the window as a whole cost time proportional to its size squared. Messages now fold into the batch in place.
Measured on a hundred-thousand-row grid, forty thousand single-row updates: 49 seconds before, 0.18 seconds after - from roughly 800 rows a second to around 230,000. Nothing about the API changed.
-
Tree parents show their totals. A totalled column rolled up under grouping but not under
tree, so a parent row and a synthesised level both showed nothing where the reference says the total covers the tree node. Both now aggregate over their descendants. -
Type-specific format options are reachable. Two of the three documented ways to give a type its options -
typeOptions, and a bag named after the type - were dropped when the column was resolved, and a plainformatobject on a self-formatting type was compiled as a number format. So{ maxUnits: 3 }on a duration printed90,061,500, and{ style: 'clock' }threw out ofIntl.NumberFormatwhile the grid was still being constructed. All three spellings now reach the type, and a genuine number format still compiles as one. -
totalFilteredOnlycan be changed after construction. It was not part of the total stage’s memo key, so switching it left the previous reduction in place - the setting changed and the number did not, and nothing short of a change that moved the grouping would dislodge it. The same applied tototalOnlyChangedColumns. -
A paste with no extent lands at its own size. The target was everything below and to the right of the anchor, and the tiling rule then filled it - a two-row block pasted into a 20,000-row grid was written 10,000 times, as a single undo entry. Passing an explicit
extentstill fills that range, which is what a selection means. -
highlightOnChangeflashes changes that arrive as data. It followedcell:changed, which only an edit emits, so a value replaced throughrows.apply({ update })- a live feed, the case the option exists for - never flashed. A cell whose value has not actually moved still does not flash. -
A refused validation no longer misdirects the next write. When a value failed validation the edit session stayed open, correctly, so it could be corrected where it was typed - but the next
setCellsborrowed it: the write landed on the refused cell instead of the one it named, and was reported as applied. It is now refused, returning0like any other write that cannot land, until the session is closed withedit.stop(true). -
A paste anchored on a non-editable column is refused. The anchor was checked against column permissions but not against
edit, so a column declarededit: falsestill accepted one. The payload was then laid out from a cell that could not be written: the first value was dropped and the rest landed one column to the left of where they were aimed. -
columns.autoSize()measures content. It read back the width the column already had, so every call grew each column by a fixed 24px and a two-character value ended up as wide as a seventy-character one. Columns now fit their contents and can shrink; calling it twice changes nothing the second time. -
A group footer is distinguishable from its group. The footer row carried no class of its own, so it was identical to the heading in the DOM and could not be styled or targeted, and it displayed a row count of
(0). It now carrieslat-row--group-footer, is markedfooter: trueon the row, and leaves the count to the heading. -
Compute kernels load in the shipped build. In
distthe worker never started: its bootstrap fetched akernel.jsthat is not part of the package, and a dynamic import inside the bundle resolved against the bundle’s own location rather than the module beside it. Together they meant the kernels failed to load in every released build, so anything crossingworkerThresholdfell back to the main thread or produced nothing - a header histogram above the threshold rendered no chart at all and logged “compute kernels could not be loaded”. The worker source now travels inside the bundle, so the package is self-contained with no file to serve alongside it. Running from source was unaffected, which is why it went unnoticed.This adds the compiled kernel that should always have been in it.
-
A duplicate column id keeps the first column, not a mangled pair. Two columns resolving to the same id - easily done, since an id defaults to the field - overwrote the first definition while still taking a second slot in the order. The result was that the first column’s settings were lost and the survivor appeared twice in
columns.all()andcolumns.visible(), while the only warning said the second had been ignored. The first now wins, the second is refused, and the warning says which column and why. -
grid.destroy()releases presence. The provider subscription, its throttle timer and the peer map outlived the grid, so a destroyed grid went on receiving peer updates and never announced that its own user had left. -
A fetched page repaints when it lands. With a paged or remote source, the exact row total arriving changed the grid’s height but triggered no repaint - the scrollbar kept its estimate and the new rows stayed unpainted until the next frame from somewhere else, usually the user’s next scroll.
-
An
objectcolumn shows its value. Cells rendered[object Object]unless the column suppliedvalue.format. They now show the same JSON form the clipboard already produced, so what you see and what you copy agree. -
An unsupported date pattern token says so. Tokens the pattern compiler does not implement -
zzzfor the zone,QQfor the quarter - were emitted as literal text, so the letters appeared in the cell. The compiler now names the token once and lists the supported set. Quote it to keep it as a literal.
Changed
-
grandTotalRowacceptstruein the type declarations. The runtime has always honoured it as an inline total row and the reference documented it; only the declaration disagreed, and it excluded the value outright. The stream source’smaxRowswas likewise declared as a property of theopen()request rather than of the source configuration, which is where it is read. -
The default density is documented as
compact. The reference saidstandard; the grid has always started atcompact(23.8px rows). No behaviour has changed. -
A cell menu offers a writing action only where it can write. Paste, Clear, Fill down and Edit cell were enabled whenever any visible column accepted edits, so on a grid with one editable column among several they were offered over read-only cells and then did nothing. Each is now resolved against the column the menu opened on - or, for the range actions, the columns the range covers. Copying is unaffected: reading is not writing.
-
Tree headings count their rows. A level named by a row’s path but with no row of its own reported
(0)however many rows sat beneath it. -
Tree rows caught in a parent cycle go to the orphans bucket. With
orphansnaming a heading, rows with a missing parent were placed under it and rows in a cycle were scattered among the real roots instead. -
A lazily loaded branch always reports how it finished. Collapsing a branch before its rows arrived aborted the fetch silently:
tree:loadingwas emitted and nothing followed it, so anything tracking in-flight branches kept a spinner turning for a fetch that had been abandoned. It now emitstree:loadAborted. -
column.headerdraws the heading. A column could declare a header renderer, props and classes; all of it was accepted and onlyalignwas ever read, so every column produced the stock heading.header.rendernow takes a function, a component, or a name registered inconfig.components, withheader.propspassed through andheader.classapplied to the header cell. -
quickFilterTextandquickModework from configuration. Both were accepted and read by nothing, so a grid built with a quick filter showed every row.quickModealso travels in saved state now: a view saved while searching inwords,fuzzyorregexcame back ascontainsand matched a different set of rows than the one it was saved showing. -
Date filters match again. A condition on a date column carrying an ISO instant -
2026-01-15T09:30:00Z- returned no rows. The column stores the wall-clock day asYYYY-MM-DDwhile the condition was converted to epoch milliseconds, so the two sides were never comparable. Both are now brought to the same form, and a filter value may be an ISO instant, a plainYYYY-MM-DD, aDateor an epoch number. Where a condition does not declare a type, the column’s is used. -
Relative date filters work against in-memory data.
op: 'relative'-last7Days,thisMonth,yearToDateand the rest - was rewritten into an absolute range only on the way to a server. Against a memory source the operator reached the evaluator unrecognised and passed every row, so the filter appeared to apply and changed nothing. It now resolves the same way in both places, per pass, so “today” still means today after midnight. -
config.totalFnsis consulted. Custom total functions registered by name were accepted and then ignored: the reducer knew only its built-ins, so a column totalled with a registered name showed no total and the console advised registering it inconfig.totalFns- which is where it already was. Passing a function inline always worked and is unchanged. -
Clicking a row selects it. Row selection was reachable from the keyboard and from the checkbox column, but a mouse click only announced
row:clicked- nothing selected. Two faults met here: the click handler never asked for a selection, and the keyboard path that did asked for a method the selection model does not have, so it was silently discarded. Clicking now selects, Ctrl/Cmd adds and removes, and Shift extends from the anchor. A click on a link, button, input or expander inside a cell still belongs to that control, and the checkbox and detail columns keep their own behaviour. -
export({ rows: 'all' })now reaches past the filters.'all'and'visible'produced identical output, so a caller exporting everything silently got only the rows the filters had left.'all'now covers every loaded row in the source;'visible'is unchanged. -
export.print()honoursunpinand its row ceiling. Neither worked from the grid: the ceiling compared against a row count it could not read, so a print of any size was allowed through, and the unpin step looked for the column API in a place the grid does not put it, so pinned columns stayed pinned and were clipped at the page edge. -
Type declarations corrected.
HistoryEntrydescribed akindfield that no entry has ever carried, and omitted the six that every entry does -seq,type,label,target,atanddelegated.toolPanel.panelslisted four built-in panels; there are five, the fifth beingformatting.EditorNamenamed seven of the twenty-two editors that ship, and the twenty-four built-in cell renderers had no declared names at all - both are now full unions that still accept your own registered names. -
WARNING_IDS.NO_ROW_KEYmatches the warning it names. The constant held an id nothing emits, so filtering diagnostics on it matched nothing. The source layer’s separate warning is now named too, asWARNING_IDS.SOURCE_NO_ROW_KEY. -
grid.diff.isChanged(key)answers the row-level question. The column argument is optional and omitting it asks whether the row changed, but the call was routed to the per-cell comparison regardless - which resolved no column and returnedfalsefor every row, including rowsstatusOf()reported aschanged. Calls passing a column id were correct throughout. -
Diff marks survive the first paint. Row and cell diff classes were written before the cell layer filled the rows, and filling a row rewrites its classes - so the marks were erased a moment after being applied and an audit grid showed no highlighting at all. They are now written after each render completes, as the other overlays already were.
-
allowUnsafeTemplatesno longer permits script. The flag turns off HTML escaping so a template can render markup, and the value interpolated into a{{{ }}}segment reachedinnerHTMLuntouched - so a row value containing<img src=x onerror=…>executed, and ajavascript:URL in a value survived into the rendered link. The compiler’s existing refusals covered dangerous tags and URL schemes written in the template, which you author and can audit, and not the value, which usually arrives from your data.Interpolated values now go through the same rules: executable tags (
<script>,<iframe>,<style>and the rest),on*handler attributes, andjavascript:/data:URLs are removed, including entity-encoded spellings. Presentational markup - emphasis, links, spans - is unaffected, as is any value with no markup in it. A string returned fromcell.renderis the same gate and gets the same treatment.Grids that do not set
allowUnsafeTemplateswere never exposed and are unchanged. This is a narrow allowance rather than a general sanitiser: to render arbitrary third-party HTML, sanitise it yourself and return an element fromcell.render. -
Esc now ends a presentation instead of only leaving full screen. A presentation runs full screen with its chrome hidden, and Escape exited full screen while leaving the presentation running - so the grid returned to the page still enlarged, still dimmed by any spotlight, with the tool panel that carries the stop control still hidden and no way to turn it off from inside the grid. An open editor or menu still answers the key first. A grid configured with
maximise: falsenow binds Escape directly. -
grid.presentation.reset()now exists. The documented method - and theRkey it is bound to, which puts the current view back as it was saved - was absent from the model, so pressingRduring a presentation raised aTypeError. -
Presentation no longer leaves an empty bar across the bottom. The status bar and the pager share one strip. Hiding both left the strip itself in place, so a presentation with no chrome still showed a blank band. The strip now goes when everything in it has gone, and stays when
chrome: ['statusBar']orchrome: ['pagination']asks for it. -
Annotations now land under the pointer on high-DPI displays. The drawing layer was sized in device pixels without a CSS size, and a canvas is a replaced element - so it laid out at its backing-store size rather than filling the grid. At a device pixel ratio of 2 a mark made 100px down painted 200px down, and the lower-right of the grid could not be drawn on at all. Displays at ratio 1 were unaffected, which is why it showed on laptops and not on external monitors.
-
presentation:capturedreports the image’s dimensions. The payload’swidthcarried the file’s size in bytes - a 1800×600 capture reportedwidth: 41030. It now carrieswidthandheightin pixels (afterscale), with the byte count asbytes. The image’s format moved fromtypetomimeType, because the event bus writes the event name ontotype, so the format never reached subscribers. -
rows.apply({ update })no longer replaces the whole row. An update is a patch: fields absent from it are left alone. Previously the patch was assigned over the row, so a delta destroyed every field it did not mention - and the loss was written through to the columnar store, so the cells read null rather than merely the row object being wrong.// before: b and c were lost, in the row and in the store grid.rows.apply({ update: [{ id: 'R1', a: 'A1' }] });Coalescing had the same fault: two partial updates to different fields inside one window kept only the last, so a feed sending
{price}and{volume}as separate messages lost whichever arrived first.The same fault also defeated the optimisation it should most help. Changed columns were computed against the raw patch, so a field the patch omitted looked like it had become undefined - marking columns dirty that had not changed and re-running the sort and filter stages that selective invalidation exists to skip.
Callers passing whole rows are unaffected.
-
Editing a row hidden by a filter no longer moves the totals. With a filter active, an update to a row outside it changed the grand total by the difference, even though that row contributes nothing to a total over the filtered set - and since the row is not on screen, nothing about the grid explained the change. Totals over the filtered set now ignore rows the filter removed, and
totalFilteredOnly: falsecounts them deliberately. -
An open editor now commits when you click away.
commit()was reachable only from the Enter and Tab key paths, so starting an edit and clicking another cell left the editor open on the first while the selection moved to the second - two cells looking active at once, and a value that was never written. Clicking outside an editor commits it, as Enter does. Clicking inside it - including a popup editor’s calendar or dropdown, which renders in the overlay layer rather than in the cell - leaves it open. -
The generated control columns no longer offer a cell context menu. The selection checkbox and detail expander columns hold controls, not data - right-clicking one offered copy, filter and edit actions with no value, column or cell to apply them to.
-
A nested detail grid keeps its own pointer and key events. A detail region hosts a whole grid inside one of the outer grid’s row elements, and both grids delegate their listeners from their own root - so clicks, right-clicks and keystrokes inside a detail reached the outer grid as well. A right-click on a nested cell opened two context menus; a double-click walked up to the inner row’s key and started an edit on whichever outer row shared it, which two grids over the same id scheme routinely do; and arrow keys moved a focus ring in the outer grid while the user typed in the inner one.
Added
-
Incremental grand totals. On a memory source the grand total row is now maintained across cell updates instead of re-reduced. A single-cell update on a million rows with four totalled columns went from 9.3ms to 0.16ms - the same cost as having no totals row at all.
{ field: 'amount', type: 'number', total: 'sum' } // nothing to configuresum,avg,countValues,minandmaxon numeric columns are maintained by difference. The reported number is unchanged: where a running value cannot be trusted the column falls back to a full pass rather than reporting a value it is unsure of. That happens when a value moves off the currentminormax, when rows are added or removed, when the filter, sort or grouping changes, and when a total grows past the magnitude at which a 64-bit float stops registering small changes. Running sums are compensated, so a long editing session does not accumulate error.A custom
totalfunction is re-reduced on every change, as before - a reduction supplied as a function has no inverse. Group totals are also re-reduced; only the grand total is incremental.Placing the grand total inline (
grandTotalRow: true) no longer rebuilds the display array on every change, which is where the remaining cost sat for large grids using the default placement. -
Two more themes, and
themenow does something.high-contrastandterminaljoin light and dark.createGrid(el, { theme: 'high-contrast' }); grid.set('theme', 'terminal'); grid.set('theme', null); // back to following the viewerconfig.themenever reached the DOM. The token sets hang off.lattice[data-theme]and nothing wrote the attribute, sotheme: 'dark'styled nothing - a grid only went dark when the viewer’sprefers-color-schemehappened to say so, and a page that set the attribute on<html>instead was not selecting the grid at all.high-contrastis not dark with more contrast. Text is 21:1 and borders 6.1:1 against the background, where the other themes sit near 1.3:1 on borders - WCAG 1.4.11 asks 3:1 for the boundaries a user has to find. Cell borders are drawn rather than implied, selected rows carry an outline as well as a fill, and every status pill has a solid border so it does not depend on hue alone.terminalis a phosphor console: one hue on near-black, monospaced, with status carried by brightness rather than colour.The variant-contrast test now covers all four themes, holding high contrast to AAA where the others are held to AA.
-
Tree data. Rows form a hierarchy, by parent reference or by path. The row model implemented this and nothing ever loaded it - the memory source builds the display array itself, so a configured
treereturned a flat list.tree: { parentKey: 'parentId', label: 'name' } // the row names its parent tree: { path: (row) => row.hierarchy } // the row carries its ancestryParent-reference is what a join or a document store produces; every node is a real row. A row whose parent is missing is an orphan - it goes to the root, or into a bucket named by
orphans, and is never dropped. Path-based rows describe their own place, so levels no row occupies are synthesised and render as group rows; a real row arriving later for a synthesised level fills it rather than appearing beside it. A parent cycle is reported once and cut, with the rows shown at the root.Branches can load on demand.
tree.hasChildrenlets a row declare children it does not hold, so the expander exists before anything is fetched, andtree.loadChildren(row, signal)supplies them when it is opened:tree: { parentKey: 'parentId', hasChildren: (data) => data.childCount > 0, loadChildren: (row, signal) => api.children(row.data.id, { signal }), }Such a node reads as closed until its rows arrive, because an open branch with nothing under it leaves no gesture to load it. The rows are added to the data set, so they sort, filter and export like any other. A branch is fetched once however often it is toggled; closing it before the rows arrive aborts the request; a rejection is reported and leaves the branch retryable rather than permanently empty.
tree:loading,tree:loadedandtree:loadFailedare on the event bus.The grid generates a tree column for the expander and indent, on the same terms as the auto-group, selection and detail columns. Its text comes from
tree.label, falling back to your first visible column. Expansion is the same state group expansion uses, sorows.expand,collapseAlland saved views all work on it, and a collapsed branch is skipped rather than hidden. Grouping andtreetogether is not a combination: the grouping wins and says so once. -
Master-detail. A master row expands into a detail region - by default a nested grid over whatever
detail.rows(row)returns. The row model implemented this and nothing constructed it with a detail factory, so thedetailconfig block was read nowhere and no row could ever expand.detail: { rows: (row) => api.lines(row.data.id), // array or promise config: { columns: [{ field: 'port' }, { field: 'vlan' }] }, isMaster: (data) => data.lineCount > 0, // default: every data row height: 240, cacheLimit: 10, } grid.detail.toggle(key);The grid generates an expander column while the feature is on, on the same terms as the auto-group and selection columns: pinned to the start, and out of
columns.visible(), saved views, exports and the tool panel. The detail is a real display row - virtualised, height-managed, pushing the rows below it down - and any number of masters can be open at once.Regions are cached by row key so collapse and re-expand does not refetch.
cacheLimitbounds what is retained after closing, not how many may be open at once; an open region is never evicted.cacheLimit: 0destroys on collapse. -
An editable detail reports its edits on the master. The detail is a whole grid, so
detail.config.editmakes its cells editable - but that grid is created by the grid, not by you, so its events were out of reach.grid.on('detail:cell:changed', (e) => { e.masterKey; // 'C1' - the row the detail belongs to e.path; // 'ports.1.vlan' - where it lands on the master's record e.value; });detail:edit:started,detail:edit:stoppedanddetail:cell:changedare re-emitted on the master, tagged with the master they came from.pathis the dot notation from the master’s record to the value that changed, worked out by identity -rows(row)usually returns an array already on the record, and that property is the prefix. A detail fetched from a server is not part of the master’s record and reportspath: null; setdetail.pathto name one anyway.detail.onCreate(grid, masterRow)hands over the nested grid itself for anything else. -
detail.target- a detail pane instead of a detail row. New. Point it at an element and the detail renders there rather than into the grid.detail: { target: '#detail-pane', rows: (row) => api.lines(row.data.id) }This is the list-and-pane layout: no detail row is created, so the grid’s row count does not change when a master opens and nothing about the list’s geometry moves. Exactly one master is open at a time - one element cannot show two details, and stacking them turns a fixed-height pane into a scrolling list of grids with no rule for how tall each should be. Expanding a second master closes the first.
grid.detail.active()names the open one.A
targetselector matching no element is reported once rather than failing silently.The expander reflects the placement. Inline it is a chevron that turns down when the row expands, with
aria-expanded- the ordinary disclosure. With a target nothing expands, so it becomes the “opens elsewhere” glyph and a toggle (aria-pressed); a chevron there would promise an expansion that never comes. The chosen row carrieslat-row--detail-activeandaria-current, because with the detail off to the side nothing else in the grid says which record the pane belongs to. -
selection.checkboxandselection.headerCheckboxnow render. Both were documented, and the second was already switched on in the demo, but no checkbox column was ever generated - setting either did nothing.selection: { mode: 'multiple', checkbox: true, headerCheckbox: true }checkbox: trueadds a narrow pinned column of row checkboxes.headerCheckbox: trueputs a tri-state select-all in its heading: unchecked, checked, or the native indeterminate mark when some rows are selected. Clicking it selects every row the filter currently shows, or clears when everything already is - including from the indeterminate state, where the intent is “select the rest”.grid.selection.headerState()returns the same tri-state, for building your own control.The column is generated, not declared: it stays out of
columns.visible(), saved views, exports and the tool panel’s visibility list, and disappears when the option is turned off. -
Selection now reaches the rows.
Row.selectedwas written by a pass over the display array, and any row rebuilt afterwards - by scrolling, by cache eviction, by any repaint outside that window - came back unselected. The keys were correct in the model and nothing on screen ever showed it: no row highlight, andaria-selected="false"reported to screen readers on every row however many were selected. Selected rows now carryaria-selectedand the classlat-row--selected, which the theme has styled all along. -
grid.selection.all()no longer throws. It called a method the selection model does not have. Every call raised aTypeError. -
workerUrlandsharedMemorynow reach the worker. Both were documented and read inside the worker package, but the grid built its worker host without them, so neither had any effect.workerUrl: '/assets/lattice.worker.js', // for a CSP that forbids blob: sharedMemory: true, // where the page is cross-origin isolatedworkerUrlmatters most: under a Content-Security-Policy that forbidsblob:workers, it is the only way a worker can be constructed at all, and without it compute stayed on the main thread with no way to change that.sharedMemoryis off by default - it avoids re-copying a column on every message, at the cost of retaining a shared copy of each column that crosses. Both are settled when the worker is constructed, so changing either discards the running worker and the next offload builds a new one. -
workerThresholdbelow 50,000 now takes effect. The grid passed the value to the worker host under the wrong name, so the host kept its own 50,000 default. A grid configured to offload above, say, 10,000 rows let the call past its own gate and the host handed it straight back to the main thread. Values above 50,000 were unaffected. -
Distributions actually cross the worker boundary. The facet call carried the column’s resolved data type, which holds functions -
parse,compare,matches- and a function anywhere in the arguments makes a call unstructured-cloneable. The one kernel wired to a worker therefore spawned one and then ran everything on the main thread. Only the type’sbaseis needed, and only that is sent now.grid.diagnostics.renders().workerreports the threshold the host is actually using, rather than the configured one, along with thesharedMemoryandworkerUrlit was built with. -
showTotalInHeader. Under grouping or pivot, a totalled column’s heading now names its reduction - a smallSUMline aboveCapacity,AVERAGEaboveMargin. The heading returns to the column’s own title when grouping and pivot are both off. On by default; setshowTotalInHeader: falseto leave headings alone.The reduction sits on its own line rather than reading
Sum of Capacityacross one, because a header cell reserves width for its sort, filter and menu buttons whether or not they are showing - on a default column the label gets 54px of 129px, and a one-line version truncated toSum o…, trading the column’s identity for its reduction. Stacked, it costs no width at all: no heading truncates that did not already.Such a heading carries
data-totalon its header cell, naming the reduction, and the two lines are.lat-header-total-fnand.lat-header-total-name, so a theme can style them;--lattice-header-total-sizeand--lattice-header-total-colorset the reduction line’s size and colour. The full phrase is on the label’stitlefor the pointer. Where a pivot has a single value column, its leaf is titled with the pivot value rather than the column’s name, and that heading is left alone.The option was previously in the defaults but read nowhere, and no heading ever named its reduction.
The tool panel’s aggregation picker now takes its names from the same table the headings use, and gained
Count of values- the reduction that counts present values rather than rows - which it had been missing. -
totalFilteredOnly: false. Totals now reduce the whole dataset when the option is off, instead of always reducing the filtered set.totalFilteredOnly: false // the filter is a lens; totals report everythingBoth the grand total and each group total follow the setting, so a group row shows the total for every row belonging to that group rather than only the visible ones - including groups the filter emptied entirely, which have no row of their own but still count toward the totals above them. The count on the grand total row follows the total, so it never reports fewer rows than the total covers.
The unfiltered row set and its grouping are computed once and reused, so the cost lands on adds and removes rather than on every edit or filter change. On 500,000 rows grouped and half-filtered, a single-cell update measured 2.1ms with the default and 3.2ms with the option off.
The option was previously in the defaults but read nowhere, so setting it to
falsehad no effect. -
totalOnlyChangedColumns. Reduce only the totalled columns an update actually changed, instead of every totalled column on every change. An update that rewrites a field with the value it already held reduces nothing.totalOnlyChangedColumns: trueOn a million rows in seven groups with four totalled columns, a single-cell update went from 15.4ms to 7.3ms when it changed one of them, and from 15.6ms to 7.3ms when it changed none. There is nothing to gain when an update changes every totalled column - the saving is proportional to the columns it leaves alone.
Off by default, because it asserts that each total depends on nothing but its own column. That holds for every built-in reduction. A
totalsupplied as a function also receives the row, the grid andconfig.context, and is only recomputed when its own column changes - so a function reading state outside its column reports the value from the last time that column moved. Adding or removing rows, filtering, sorting, grouping, and changing which columns are totalled all reduce everything again regardless of the option.The option was previously documented and declared in the type definitions but had no effect.
-
Collaborative presence. See who else is on the grid and what they are doing: cursor, selection, active edit, an optional advisory lock, and a peer roster you can click to jump to someone.
presence: { provider, // your transport: subscribe + publish me: { id: 'u_17', name: 'Tony' }, lock: true // advisory - see below }The grid never opens a connection. You supply the transport and the identity; a WebSocket, MQTT, a CRDT library or a polling endpoint all satisfy the interface, and without a provider the feature is inert.
Presence carries intent, never values. A peer’s committed edit must reach the grid as data, through whatever channel you already use. Presence is throttled and lossy by design, so a value carried on it is a value that can be dropped.
Positions travel as row key plus field, never index, so peers who sort and filter differently still see each other on the right records. A peer whose row is not in your view is held, counted, and shown in the roster as “not in view” - not dropped, which would read as a disconnection.
Idle and removal are inferred from local receipt time rather than the timestamp in the message, because clocks between clients disagree. Publishing is throttled rather than debounced, and stops while the tab is hidden.
A peer’s cursor is drawn dashed against your own solid focus ring, so the two can never be confused; their name shows briefly when they move, then fades to the bare border. Nothing is inserted into the grid, so presence cannot shift layout, cover an in-cell chart, or intercept a click.
Locking is advisory. It reduces collisions and does not eliminate them: two clients can enter an edit in the same instant. The authoritative resolution is the conditional write in
edit.commit, which returns a conflict and rolls back. A refused edit announces who holds the cell, because one that silently will not open is indistinguishable from a broken grid.grid.edit.start()now returns whether a session began, which it was previously discarding. -
Flush strategies, a queue ceiling and a frame budget. Queued changes now flush on a frame by default rather than a 50ms timer, which is what makes one repaint per batch reliable - a timer can fire twice between two paints.
updates: { flush: 'frame', // or 'microtask', 'interval', 'manual' maxQueued: 20000, // force an early flush, whatever the strategy budgetMs: 10, // defer the rest of a long flush to the next frame }A frame and a timer are armed together and the first wins. In a foreground tab the frame always wins; in a backgrounded tab, where the browser stops firing animation frames entirely, the timer keeps the feed applying rather than the grid stalling with every queued promise unresolved.
Over budget, a flush returns the remainder to the queue and it lands next frame. The promise a caller holds resolves when their rows land, not when the first slice does.
updates.stats()gainsstrategy,deferrals,maxQueuedandbudgetMs. -
Per-row rejection reporting.
apply()andqueue()results carry arejectedlist of{ operation, id, reason }. A batch of a thousand rows containing three bad ones applies the other 997 and names the three.unknown-idcovers an update or remove for a row that is not there;duplicate-idrefuses a second row under an existing key, which would otherwise corrupt selection, expansion, comments and the key index at once. Rejections are reported, never thrown. -
Diagnostics, and a devtools panel.
grid.diagnosticsreports what the grid is doing: render counts and their causes, memory layout, operation and provider timing, listener counts, effective configuration, and a list of conditions that look like mistakes.const before = grid.diagnostics.renders().dom.cellWrites; await doTheThing(); expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);The API is the interface and the panel consumes it, so the figures above can be asserted in your own tests rather than only looked at.
grid.diagnostics.bundle()produces a support bundle - configuration, query state, timing, warnings, provider statistics, version and environment. It contains no row data, cell values or column values, so it can be attached to a ticket without being read first.Warnings each carry a stable id, a plain description and the values involved: duplicate row keys, a filter naming a column that does not exist, an options object changing identity without changing content, listener counts growing without bound, a large operation running on the main thread, and a slow provider. All of them produce no error on their own, which is why they are worth detecting.
The panel is an optional module that imports nothing - the grid is handed to it - so deployments that never load it pay nothing.
import { createDevtools } from '@toclocoinc/lattice-grid/modules/devtools'; createDevtools({ grid }); // Ctrl+Shift+D collapses itNine tabs, a compact vitals strip, and a render heat overlay that tints cells as they are written: blue for a new value, red for a cell rewritten with the value it already had. The second is the one worth chasing.
The Accessibility tab runs live checks against the rendered grid - ARIA roles and counts, cells with no accessible name, and WCAG AA contrast against the active theme. It checks what was drawn rather than what was configured, because a custom cell renderer can pass every configuration check and still leave a cell unnamed.
The panel observes and never mutates, and nothing leaves the browser.
-
Cell comments. Threaded discussion attached to individual cells, for reviewing data with other people without leaving the grid. A commented cell carries a small triangle in its upper-right corner; clicking the corner opens the thread.
comments: { provider, // without one the feature is inert rowLabel: (row) => row.data.name // so the panel says what is being discussed }The grid owns presentation and interaction only. Storage, identity and permissions stay yours, reached through an asynchronous provider - load the index, load a thread, add, edit, delete, resolve. Rejections roll back an optimistic write rather than leaving the grid showing something your server refused.
A stable
rowKeyis required, and comments are disabled without one rather than falling back to row index. They are keyed on row identity plus field and outlive the values they annotate, so index identity would reattach every thread on the next sort. The requirement is stricter than it first looks: identity must be stable across sessions and across data reloads, not only within one session.The grid authorises nothing. A comment may carry
can: { edit, delete, resolve }and the affordances follow it, but hiding a button is a convenience for the reader and never a control - your provider rejects what it must. Author names and avatars are rendered exactly as supplied; the grid does not know who the user is.Comment bodies are text. Optional
markdown: trueadds emphasis, code and links only, built as elements rather than parsed as markup, with any link scheme other thanhttp,httpsandmailtorefused.A comment records the value it was written against and shows it whenever the cell has since moved, so a note does not appear to contradict what is on screen. Changing a value never removes a comment.
Comments follow their row through sorting and grouping. When a row is filtered out its comments are hidden rather than lost, and
comments.hiddenUnresolved()reports what is still outstanding out of sight.comments.filterToCommented()restricts the grid to rows carrying comments, and refuses rather than half-applying untilloadAll()has covered the whole row set.Comments remain available while streaming. A thread whose row is evicted by a bounded window closes with an explanation.
Alt+Mopens the thread on the focused cell. The panel traps focus while open and restores it to the cell on close, and commented cells announce their count and unresolved count to screen readers. -
Column header histograms, and filtering by clicking them. Each column heading can carry a distribution chart that is also a filter control. Click a bar to filter to that bucket, drag across bars on a numeric or date column to filter to the range. As filters are applied the other columns recount, so a dataset can be explored by clicking through headings rather than opening a dialog.
facets: { enabled: true } // per column, layered over the grid's settings { field: 'price', type: 'number', facet: { strategy: 'quantile' } } { field: 'notes', facet: false }Off by default, because the band roughly doubles the header’s height.
A column is never counted against its own filter. Every other active filter applies; that column’s own conditions are removed before counting, so clicking a bucket dims the others rather than collapsing the chart to one bar. Without that there is no way to see what you excluded or to widen the selection.
The filters this produces are ordinary filters. They undo, serialise into saved views, and appear in the existing filter UI - nothing downstream can tell them from a filter typed into the filter panel. A drag emits a
betweenrange rather than a set of bucket indices, so it still means the same thing after the data is replaced.Numeric columns take
equal,quantileorlogbucketing; date columns pick a granularity from their span; text columns get one bar per value, ordered by count. Text columns abovecardinalityLimitdistinct values are suppressed - a name column has no readable histogram - or show a top-N with an aggregated remainder underaboveLimit: 'topN'. Nulls and NaN land in a terminal bucket rather than being dropped, so the counts always sum to the row count.Each bar shows two readings: its full height is the bucket’s share of the unfiltered column, and the solid fill inside is how much survives the current filters.
Charts are keyboard operable - arrows move between buckets,
Entertoggles,Shiftwith arrows extends a range,Escapeclears - and carry a sentence describing the distribution’s shape for screen readers.Live streams suppress the charts, because buckets moving under the pointer make the control lie about what clicking it will do; pausing the stream brings them back. Paged and remote sources need a
facets.providerto supply counts, and without one the charts are silently absent. -
Compute now runs in a Worker. Distribution counting is offloaded above
workerThresholdrows. This is the first kernel to use the Worker path; sort, filter and grouping continue to run on the main thread. -
Time scrubber.
grid.timelinemoves the grid back through recent data changes - what a row held a minute ago, before the number moved.grid.timeline.attach(); // start recording grid.timeline.seek(5); // five changes back grid.timeline.toLive(); // returnAttaching puts a scrubber on the grid: a slider with how long ago and the clock time beside it, updating as you drag rather than on release. It turns accent-coloured whenever you are off live, and removes itself on
detach().The window it reads is bounded two ways:
updates.logLimitchanges (2000) andupdates.logRowsrows between them (100,000), dropping oldest-first on whichever it reaches. Both are configurable. A cap on changes alone does not bound memory, because one change may carry a single cell and the next fifty thousand rows.grid.updates.stats()reportsheldagainstheldLimit.Cells whose value moved during a seek are marked, and stay marked until the next seek - on a wide row the change you are hunting for is easy to scroll past, and a flash you can miss while reading the other end of the row helps nobody. The colour is
--lattice-timeline-changed. Marking compares rendered column values rather than raw fields, so a computed column that moved because its inputs moved is marked too. Chart columns redraw as you scrub, like any other cell.It reads the change log rather than the undo history: history records what you did, and the question on a live grid is what the data did. Seeking backwards writes back what each change replaced; seeking forwards re-applies the changes themselves.
Recording is off until
attach(), because what a value used to be is not recoverable afterwards, and reading a row per key on every change is real cost on a busy feed. Value changes reverse; row additions and removals do not, and a window containing them scrubs over the value changes and leaves the row set alone. Thedeltarenderer is the one cell type to keep off a scrubbed grid: it samples on a wall-clock timer, so it reads a seek as a real movement and draws an arrow for it. -
Bounded windows for streaming sources.
source.maxRowsturns an endless stream into a sliding window: the oldest rows are dropped as new ones arrive, so a grid left up overnight holds a fixed number of rows rather than every row it was ever sent.source: { mode: 'stream', maxRows: 50000, open: … }Omit it for the previous behaviour, which is no limit. The progress report gains
liveandevictedso a status bar can show the window. Evicted rows are tombstoned rather than spliced out, because the physical index is row identity for the life of the source and shifting it would silently move every cached sort, selection and expansion. The viewport is compensated when rows are trimmed above it, so a user reading history does not have the floor drop out from under them. -
Hold live updates, and see what coalescing saves. A pause button in the action rail, and
grid.updatesbehind it.grid.updates.pause(); grid.updates.resume(); // apply everything held grid.updates.stats(); // { pending, queued, coalesced, ... }Changes keep merging while paused, so forty updates to one row are one row of work when play is pressed.
coalescedis the difference - the number that made the throughput claim true and that nothing previously reported. The status bar shows it, and shows the backlog while paused.grid.updates.log()returns what arrived, in order, with timestamps. It keeps the raw sequence rather than the merged one, survives the flush, and is capped so a grid paused indefinitely holds the recent past rather than all of it.Pausing is deliberately a control rather than something inferred from whether the user seems busy: that heuristic is wrong in both directions, and a grid that stops updating for reasons nobody can see is worse than one that keeps moving.
-
Jump to a page by typing it. The page number in the pagination bar is an input rather than a label: type a number, press Enter. On a thousand-page grid the last-page button and a run of clicks were the only ways to reach page 400.
Out of range clamps to the last page. A blank or non-numeric entry restores the page you were on rather than jumping to the first - a mistype is not a request to go home. Escape abandons the edit.
-
Presentation mode.
grid.presentation.start()renders the grid for a room: full-screen, application chrome hidden, everything enlarged. The data stays live and interactive throughout.grid.presentation.start(); // 1.5x by default grid.presentation.start({ scale: 2, chrome: ['statusBar'] }); grid.presentation.nudge(1); // live, or Ctrl/Cmd + grid.presentation.stop(); // or EscapeThe scale multiplies the configured density rather than replacing it, so a
spaciousgrid presented at 1.5x is still that grid, half as big again. Virtualisation follows the enlargement. Separators soften and selection strengthens for reading at distance, and numeric columns take tabular figures at heavier weight.Full-screen goes through the existing maximiser: a grid already maximised stays maximised when the presentation ends. Chrome hidden on entry is recorded and restored, so anything the host had already hidden stays hidden.
Saved views are the slides. Pass a sequence and step through it:
grid.presentation.start({ views: ['escalations', 'at-risk', 'margin-watch'] });Arrow keys, space and Page Up/Down move between them; Home and End jump to the ends; R puts the current view back as saved, discarding whatever the presenter sorted or filtered after arriving at it. Each view applies through the ordinary
views.apply, so a view means exactly what it meant before.The stepping keys bind only when there is a sequence, and never while something is being typed into - a quick filter answering a question from the room does not advance the deck on the space bar. Stepping past either end sits there rather than wrapping. A position indicator names the view and its place in the sequence.
Transitions are a cross-fade and honour
prefers-reduced-motion.Spotlight.
grid.presentation.setSpotlight({ keys, colIds })dims everything it is not on - rows, columns, or the cells where they intersect. Dimming stops short of illegible so the audience can still see there is more data; it is opacity alone, so a dimmed sparkline keeps its colours. A spotlight is transient and does not survive a view change.Redaction travels in views and undo. It is part of grid state now, so a saved view carries its own masking and toggling it undoes like any other change.
Auto-advance cycles a sequence for an unattended wall display:
start({ views: [...], autoAdvance: 15000 }). It wraps, unlike a keypress.chrome: ['statusBar']keeps named chrome visible.Annotation layer.
grid.annotate.use('pen')puts a transparent canvas over the grid to draw on - freehand, straight arrows, rectangles and a highlighter.grid.annotate.use('arrow', { colour: '#e0245e' }); grid.annotate.undo(); grid.annotate.clear(); grid.annotate.use(null); // hand the grid backIt never reads or writes data. The canvas is not created until a tool is first chosen and is
pointer-events: nonewhenever none is active, so scrolling, selection and editing pass straight through. Marks are stored in content coordinates, so a circle drawn round a cell travels with that cell as the grid scrolls. They are cleared when the presentation ends, and a capture taken while they are on screen includes them.Still capture.
grid.capture({ scale: 2 })renders the grid as it stands to a PNG, for the cases where a static image genuinely is what is wanted.const blob = await grid.capture({ scale: 2 }); await grid.capture({ scale: 3, fileName: 'q3-margins.png' });It photographs the browser’s own rendering rather than redrawing the grid, so every decoration, sparkline and pill comes out as it appears. Cross-origin images are refused before the work starts, naming the offending URL, because they taint the canvas and would otherwise fail at the last step with an error that names nothing. Web fonts need embedding to appear; the default
system-uistack is unaffected. Firespresentation:captured.Events:
presentation:started,presentation:ended,presentation:scale,presentation:changed,presentation:view. -
Density is one number, and it now takes effect. Four presets, each setting a single scale that every geometry token derives from - row height, spacing, decoration sizes, and type at a damped rate.
createGrid(el, { density: 'spacious' }); createGrid(el, { density: 1.4 }); // between the presets grid.set('density', 'compact'); // livepreset row font compact23.8px 12.7px standard28px 13px comfortable42px 14px spacious56px 15px Type is damped rather than scaled: the row doubles across that range while the font moves about 18%.
Virtualisation follows the resolved
--lattice-row-height, so overriding that token by hand now moves the rows as well as the padding. An explicitrowHeightin config still overrides both.standardreproduces the geometry the grid rendered before, so a grid that never asked for a density is unchanged. -
twolinecell renderer - a bold primary line over a quieter secondary one, taken from a second property of the same row.{ field: 'name', cell: { render: 'twoline', props: { secondary: 'email' } } }secondarytakes a dot path into the row’s data, or a function;formatdecorates the result. It reads the row rather than another column, so the second line needs no column of its own.Both lines truncate rather than wrapping, and the accessible name carries both as one string. A row with no second line collapses to a single centred line. Two lines need the room - pair it with
density: 'comfortable'or taller. -
type: 'image'treats a column’s value as a URL and draws it - avatars, logos, thumbnails.{ field: 'avatar', type: 'image', cell: { props: { shape: 'circle' } } }Props:
shape(rounded,circle,square),size,fit,alt,loading. Omitsizeand the box follows the density scale.Only image URLs load:
http,https,blob:anddata:image/. Anything else is refused, including adata:URL claiming another type. A missing or broken image leaves the box empty rather than shifting the column, and every export carries the URL rather than markup. -
Redact a column while presenting. Right-click a column heading and choose Redact column to obscure its values for a screen share. The row count, the sort, the filters and the layout all stay readable; only the values go.
grid.redaction.toggle('salary'); grid.redaction.set(['salary', 'bonus']); grid.redaction.clear();Column headings stay legible so a redacted column can still be identified and turned back on. The pinned totals row is redacted along with the values, because a filtered total is the value rather than a hint at it.
Redaction is presentational. The values remain in the model, the DOM, the clipboard and every export, and anyone with access to the page can read them. For a value that must not reach the browser at all, use
permissionswithwriteOnly.--lattice-redaction-filtersets the treatment and defaults toblur(5px) contrast(0.85). It accepts anything the CSSfilterproperty does, including an SVG filter for a mosaic. -
Column headings have a context menu. Right-clicking a heading previously did nothing. It now offers Redact column and Hide column; the header menu button still carries the full set of sorting, pinning, sizing and grouping actions. New event:
header:contextmenu. -
Optimistic writes with rollback. Supply
edit.commitand the grid tracks whether each edit reached your server, rolling back the ones that did not.edit: { enabled: true, commit: async ({ key, colId, value }) => { const res = await fetch(`/api/rows/${key}`, { method: 'PATCH', body: JSON.stringify({ [colId]: value }), }); if (!res.ok) throw new Error(await res.text()); // rolled back }, }Resolving confirms the write; throwing rolls the cell back and fires
cell:revertedwith the error asreasonand the refused value asrejected, so you can offer a retry. Three events carry the lifecycle:cell:pending,cell:confirmedandcell:reverted. Pending cells are marked with--lattice-pending-background, rolled-back ones flash--lattice-reverted-background.For a backend that acknowledges on a different channel - a websocket, an event-sourced projection - set
confirm: 'manual'and callgrid.edit.settle(id, ok, reason)when the answer arrives. The id comes fromcell:pending. The mode is always declared rather than inferred from whatcommitreturns.A rollback restores the last value a confirmation vouched for, which is not always the value immediately before the failed write: if later edits have replaced it, they win and nothing is written back. Undo of an edit still in flight sends a compensating write rather than cancelling the request.
Without
edit.commitnothing changes - writes are durable the moment they are made, exactly as before, and none of the new events fire.New:
grid.edit.settle(),grid.edit.pending(),grid.edit.status(), andedit.pendingTimeoutfor the stale-write warning.grid.edit.setCells()andgrid.edit.pasteInto()are now declared in the published types; both already existed at runtime. -
Framework adapters for React, Vue 3 and Svelte. One optional bundle each, shipped as
dist/modules/react.esm.js,dist/modules/vue.esm.jsanddist/modules/svelte.esm.js.// React const LatticeGrid = createLatticeGrid({ React, createGrid }); <LatticeGrid columns={columns} rows={rows} rowKey="id" onCellChanged={fn} /> // Vue 3 const LatticeGrid = createLatticeGrid({ vue, createGrid }); <LatticeGrid :columns="columns" :rows="rows" @cell-changed="fn" /> // Svelte - an action const lattice = createLatticeAction({ createGrid }); <div use:lattice={{ columns, rows, rowKey: 'id' }} on:cell-changed={fn}></div>Props are the configuration keys you already know, plus
sort,filters,quickFilterandselectedKeys, plus one callback per grid event -cell:changedisonCellChangedin React,@cell-changedin Vue,on:cell-changedin Svelte.A changed prop is pushed into the live grid through the ordinary public API. The grid is never rebuilt on a re-render, so scroll position, selection, expansion state and any open editor survive. Change detection is reference equality, so hold
columnsandrowssteady between renders and hand back a new array only when the data has actually changed.The framework and
createGridare passed to a factory rather than imported. Lattice still ships zero dependencies, and each adapter bundle contains only glue - no second copy of the grid, and no version of it to disagree with the one you already loaded. The grid instance stays reachable: a forwarded ref in React,exposein Vue, your own reference in Svelte.Svelte is an action rather than a component because
{ update, destroy }over a node is already the shape of the work, and it needs no framework runtime at all. -
<lattice-grid>web component. A self-contained module bundle that registers a custom element on import - one script, one tag, no build step.<link rel="stylesheet" href="dist/lattice-grid.min.css"> <script type="module" src="dist/modules/webcomponent.esm.min.js"></script> <lattice-grid row-key="id" columns='[{"field":"id"},{"field":"city"}]' rows='[{"id":"A","city":"Leeds"}]'></lattice-grid>Scalars are attributes -
theme,density,locale,row-key,row-height,header-height,auto-height,selection. Structures are properties:rows,columns,config.rowsandcolumnsalso accept JSON attributes, for a page whose markup comes from a server template. Attribute changes update the grid in place rather than rebuilding it, so scroll position, selection and an open editor survive.The underlying grid is on
.grid, giving the full imperative API.Grid events are re-dispatched as
CustomEvents namedlattice-plus the grid name with colons hyphenated, socell:changedbecomeslattice-cell-changedand the payload is onevent.detail.The element renders in the light DOM, so the
--lattice-*tokens and any CSS written against the grid’s classes work exactly as documented.The bundle carries the grid with it: load it or
lattice-grid.esm.js, not both. Optional module bundles are now minified as well, sodist/modules/*.esm.min.jsexists alongside the readable build. -
The fill handle continues a series. Dragging it now extends what it can recognise instead of repeating the selected block.
1, 2, 3 // → 4, 5, 6 5, 10, 15 // → 20, 25 15 Jan, 15 Feb // → 15 Mar, 15 Apr whole months, holding the day 31 Jan, 28 Feb // → 31 Mar, 30 Apr month ends stay on the month end 'x', 'y' // → x, y, x, y unrecognised, so it repeatsDetection runs per column, so dragging several columns down continues several independent series. A single source value copies rather than incrementing, and anything unrecognised repeats as before. Month steps hold the day of month and clamp where the month is short.
Where every source value is the last day of its own month, the fill stays on the month end rather than continuing in days - including 29 February in a leap year. Values that share a day of month keep the day-preserving answer, so
30 Apr, 30 Junstill gives 30 August.Filling upwards is not supported; the handle extends downwards only.
selection.filltakes precedence when supplied, for domain series the grid cannot infer. It must return one value per target row. -
selection.addRange(range)adds a cell range without discarding the others - the API form of ctrl-clicking a second block. The added range becomes the anchor thatextendRangegrows.startRange,extendRange,cornerandinRangeare now declared in the published types alongside it; all four already existed at runtime. -
Ctrl/Cmd + Shift + arrow selects a second range from the keyboard. The first press opens a block at the focused cell without discarding what is already selected; the presses after it extend that block, so holding the chord draws one rectangle rather than one per key repeat. A plain Shift + arrow goes back to extending a single block.
-
Conditional formatting an end user can change.
grid.formattingholds rules as runtime state rather than compiling them at configuration time, so a user can add and reorder them while the grid is running.grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } }); grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } }); grid.formatting.move('margin', ruleId, 0);A scope is a column id, or
'*'for every column. The two are evaluated as one ordered list - grid-wide rules first, then the column’s own - so a column rule can override a grid-wide one andstopIfTruebehaves the same across the join as within either half.Rules travel in saved views and undo like any other change. They must be JSON, so
stylecannot be a function here; config-timecell.stylestill accepts one and is unchanged. Where both apply they are merged, and the runtime rule wins only for the properties it names. Group rows are not formatted.A
formattingtool panel edits them: add, reorder, enable, disable and remove, per column or across the grid. Add it withtoolPanel: { panels: ['columns', 'filters', 'formatting'] }.Icon sets and data bars are not in the panel yet. Both are column decorations rather than cell styles, and driving the existing
barandicondecorations from the panel needs a runtime column-decoration API.New:
grid.formatting, theformattingconfig option, and aformatting:changedevent. -
Conditional formatting rules.
compileRules()turns a rule list into the functioncell.stylealready accepts.{ field: 'margin', cell: { style: compileRules([ { when: { op: 'lt', value: 0 }, style: { background: '#fdecea' } }, { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } }, ]) } }Conditions use the same operators as filters. Rules evaluate in order and the first match wins unless
stopIfTrue: false. Colour scales take two or more stops between a givenminandmax. A blank cell satisfies no comparison, and values typed as text still compare numerically.Exported:
compileRules,testRule,mixColour,RULE_OPS. -
Quick filter matching modes.
grid.filters.quick(text, { mode })acceptscontains(the default and unchanged),words,fuzzyandregex.grid.filters.quick('acme london', { mode: 'words' }); // every term, any column grid.filters.quick('crc', { mode: 'fuzzy' }); // characters in order grid.filters.quick('^CIR-[12]', { mode: 'regex' });The mode persists until changed, so text alone can be passed on each keystroke.
grid.filters.quickState()returns{ text, mode }.An invalid regular expression falls back to a literal search, so a pattern being typed does not empty the grid.
fuzzydecides what stays and never reorders. Matching remains restricted to columns the viewer may see. -
In-cell charts. Seven renderers:
line,area,column,winloss,pie,donutandbullet.{ id: 'trend', field: 'readings', cell: 'line' } { id: 'mix', field: 'split', cell: { render: 'donut', props: { hole: 0.55 } } } { id: 'sla', field: 'uptime', cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } }The sparklines read an array from the cell’s value, or from another property named by
series.bulletreads a number and compares it totargetover optionalbands.Props:
series,min,max,label,marker,hole,target,bands. Pinminandmaxto give several columns a shared scale.Values that are not numbers are treated as gaps: a line breaks across them and a bar is omitted. Each chart is a single SVG whose path data is all a repaint writes, and the chart is
aria-hiddenwith a text summary on the cell. -
delta- a direction arrow and the movement over a sampling interval.{ id: 'px', field: 'price', cell: { render: 'delta', props: { interval: 1000, show: 'both' } } }Every
intervalmilliseconds the column is sampled and each row compared with its value at the previous sample. One timer serves the whole column, and history is keyed by row key so it stays correct as rows recycle, sort and filter.mode: 'against'compares with another property - an opening price, a target - and runs no timer. Direction is carried by the arrow glyph as well as by colour. -
stacked,rangeandgauge.stackedshows how one row’s total divides, comparable down a column in a way a pie is not.rangeshows the span a set of values covers with the middle marked, for aggregates where an average hides the spread.gaugeshows one value as a dial, for a level rather than progress towards completion.
Fixed
-
The time scrubber can be dragged. The overlay layer it mounts into never intercepts pointer events, and each overlay opts back in; the scrubber did not. It rendered correctly and could not be dragged, clicked or focused.
-
The scrubber’s return button reads “Go live”. It said “Live”, the same word the state label beside it uses, so the two read as one thing said twice rather than as a state and the way back to it.
-
rows.apply()androws.queue()now reach the columnar store. They updated the row objects and not the store the kernels read, so on any grid large enough to be columnar the documented API for live data feeds silently did nothing: the value changed in your object and the cell, sorts, filters and totals all went on reporting the old one. Inline editing was unaffected and always wrote through correctly. -
The formatting panel’s reorder and delete controls now respond to a click. The buttons carry a class that sets
pointer-events: none- correct where it dresses an icon inside a button, wrong where the element carrying it is the button. A pointer aimed at one landed on the list row behind it, so the controls worked when driven from a script and never when clicked. -
The Formatting tab in the tool panel draws its icon. It asked for an icon the set did not contain, so the tab rendered as an invisible button and the panel could only be reached by knowing where to click.
-
The action rail groups the export buttons last, behind a divider. Export, Excel, Copy and Print sat among Undo, Redo, Restore and Maximise; they are the only actions whose effect leaves the page, and mixing them in invited clicking Print while meaning Restore. A bare
'-'intoolPanel.actionsrenders a divider, so a host reordering the rail can keep its own grouping. -
Copying several cell ranges no longer produces misaligned rows. Blocks stacked over the same columns, or joined over the same rows, copy as before. A diagonal pair has no rectangular form, so
grid.export.rangeText()now returns''andclipboard:copyreportsreason: 'discontiguous'instead of emitting rows whose columns mean different things on different lines. -
The fill handle is hidden while several ranges are selected, and
fillToandfillDowndecline, rather than silently filling from the first block.
1.3.0 - 2026-08-17
Added
-
Formulas in cells. A leading
=in a numeric cell is evaluated and the result stored:=5 + 5 =quantity * unitPrice =[Unit Price] * 1.2 =ROUND(quantity * unitPrice, 2) =IF(quantity > 10, "bulk", "single") =SUM(readings)References name columns of the same row rather than cells, matched on field or title and ignoring case and spacing. Bracket a name containing spaces. A row property with no column of its own is also reachable, so an array such as
readingscan be summed or averaged.Functions:
SUM,AVERAGE/AVG,MIN,MAX,COUNT,PRODUCT,ROUND,ROUNDUP,ROUNDDOWN,ABS,FLOOR,CEILING,SQRT,POWER,MOD,IF,AND,OR,NOT,COALESCE,CONCAT,LEN,UPPER,LOWER,TRIM,LEFT,RIGHT. Add your own withformulaFunctions.Operators
+ - * / ^with parentheses, comparison operators forIF, and postfix%.^is right-associative and unary minus binds tighter than it, matching Excel.Formulas are evaluated with a purpose-built parser. Neither
evalnornew Functionis used, so a formula can perform arithmetic and nothing else.The result is stored rather than the expression, so a formula commits as a single undo step and passes through the column’s validation like any other edit.
evaluateFormula,parseFormula,referencesOf,looksLikeFormulaandFUNCTIONSare exported for use outside a cell.
Fixed
-
Numeric cells reject unparsed arithmetic. Text such as
2*3or10/2is refused rather than stored, so the cell keeps its existing value. Enter=2*3to calculate.Values the numeric reader accepts are unchanged:
1,234.5,(50),12%,£1,000and1e3.
1.2.0 - 2026-08-17
Added
-
Custom items in the cell menu.
contextMenuaccepts a function(params, defaults) => items, receiving the cell that was clicked and the built-in items:contextMenu: (params, defaults) => [ ...defaults, { separator: true }, { name: `Open ${params.value}`, action: (ctx) => open(ctx.data.id) }, ]paramsand each item’saction(ctx)receive{ key, colId, value, row, data, column, index, grid }, wheredatais your own row object. Return the items to show; an empty array suppresses the menu, and returning nothing leaves the defaults in place. -
Custom buttons on the left rail.
toolPanel.actionsaccepts objects as well as built-in names, positioned where they appear in the list:actions: ['undo', 'redo', { name: 'sync', title: 'Sync to the server', icon: 'restore', run: ({ grid, keys, cells }) => api.sync(keys), enabled: () => grid.state.modified(), }]titleandiconmay each be a function, re-read on every repaint. -
Excel, clipboard and print on the rail and in the cell menu, alongside CSV. Rail actions are now
undo,redo,export,excel,clipboard,print,restore,maximise. The cell menu adds Excel for visible and selected rows, “Copy visible rows”, and Print.An explicit
toolPanel.actionsarray replaces the default rather than extending it. Add the new names to include them, or omit the key to take the current default. -
rows.matchCount()- data rows passing the filters, across every page, excluding group headers, footers and totals.
Fixed
-
Custom items supplied through
contextMenunow appear in the menu. -
The status bar counts data rows on a grouped grid, excluding group headers from the total.
-
Paste,Clear,Fill downandEdit cellare disabled on a grid that accepts no edits.Copy,Copy with headers, the exports andClear selectionare unchanged.
1.1.0 - 2026-08-16
Added
-
Maximise. The rail’s last button fills the browser window with the grid; clicking it again, or pressing Esc, returns it to the page.
grid.maximiseexposesenter(),exit(),toggle()andactive()for binding your own control, andmaximise: falseremoves both.The host element is moved to
<body>and pinned to the viewport, then returned to its original position. A placeholder holds its space, so the page behind keeps its layout and scroll position. Inline styles are restored exactly. While maximised the element carries.lat-maximisedand<body>carries.lat-maximised-host. -
The grid is insulated from the host page’s CSS. Elements the grid creates are given a baseline for margin, padding, border, radius, background, shadow, text transform and letter spacing, plus type and colour on form controls. A page-level rule such as
section { padding: 5.5rem 0 }no longer affects the grid.No
!importantis used. Any rule of yours aimed at a Lattice class continues to take precedence, so deliberate overrides work as before. The reset covers box model and decoration only, neverdisplay,positionor dimensions, and applies only inside.lattice. -
Complete type declarations.
lattice-grid.d.tsnow covershistory,views,diff,permissions,ai,licence,pagination,highlight,maximise,getVersion,readyandconfig. -
dist/package.jsonnamingtypes,exports,main,moduleandstyle, withLICENSEandREADME.mdalongside, so the distribution is self-contained and editors find the declarations without configuration.
Changed
-
Licensing is one product. Every copy is feature-identical; a licence removes the trial watermark and unlocks nothing. The watermark depends only on where the grid is running: loopback hosts never show one, and any other host without a valid key always does.
-
The trial watermark is larger and moves between the four corners. It takes no pointer events except on its own link, so clicks, drags and keystrokes reach the grid beneath it. Licensed grids and loopback hosts are unaffected.
.lat-watermarkcarries a--br/--bl/--tl/--trmodifier for the corner in use.
Removed
-
GridModule.tieris no longer part of the module type. Modules that set it install exactly as before; only a TypeScript build naming the field is affected. -
Public documentation no longer describes how licence keys are constructed or issued. It covers what a licence does, where to obtain one and how to install it.
Fixed
-
calc()expressions are preserved in the minified stylesheet. Spacing around operators inside math functions is required by CSS and is now retained, so padding and spacing inlattice-grid.min.cssmatch the unminified build. Recommended for anyone using the minified stylesheet. -
Column widths account for the scrollbar gutter. Columns are laid out against the space actually available, so a grid with a pinned end column can scroll its rightmost centre column clear of it.
-
Reference corrections: the methods are
grid.diff.setSnapshot()andgrid.ai.schema().
1.0.1 - 2026-08-15
Added
grid.getVersion(), and a matchinggetVersion()on the module. Theversion()export continues to work.
Fixed
-
Demand-driven sources load their data.
remoteandpagedsources fetch the blocks covering the viewport as it moves.The request shape is
{ range: { start, end }, sort, filters, quick, groupBy, … }.
Documentation
-
docs/api-detail.html- a developer guide covering what each part of the API does, with worked examples, alongside the reference tables indocs/API.html. -
This changelog.
1.0.0 - 2026-08-15
First release.
The grid
- Columnar store with typed arrays, dictionary encoding, presence bitsets and index permutations. A six-stage memoised pipeline - filter, sort, group, total, pivot, flatten - where a change re-runs only the stages it affects.
- Virtualised DOM renderer. Rows and cells come from pools and are reassigned rather than destroyed; the only vertical write is a transform. A 1,000-row change against a 20-row viewport touches 20 rows of DOM.
- Headless core with no DOM dependency, for tests and server-side export.
- Zero dependencies, at runtime and at build time. No bundler, no framework wrapper, no icon font, no date library.
Columns and data
- 20 built-in data types beyond text, number, boolean and date: durations, units and bitrates, IP addresses and CIDR, hex, binary and octal at several widths, colours, ratings, JSON and more. Each is a bundle of format, parse, compare, storage, Excel and clipboard behaviour.
- Custom types through
dataTypes, withcreateRadixTypeandcreateUnitTypeexported for building them. - Formatting for numbers, dates, booleans and text, including currency, percentages, compact notation and pattern-based dates.
- Lookup columns that store an id and display a label, with async option sources and search.
- Computed columns with a dependency graph, so a change to one field invalidates only what reads it.
Interaction
- Inline editing with a catalogue of editors: text, number, date, time, select, multi-select, tree select, object picker, colour, slider, rating, segmented, password, code and more.
- Range selection with drag, Shift+arrow extension and a fill handle. Copy in the tab-separated form a spreadsheet reads, paste with Excel’s tiling rules, fill down, and clear.
- Keyboard navigation across cells, rows and pages, with the grid keeping its hands off keys typed into an input.
- Sorting, filtering, grouping, pivoting and column reorder, resize and pin, driven from the header, the tool panel or the API.
Product features
- Saved views - named states, both developer-defined and user-saved, with a picker. Applying one is a destination rather than a patch: the same view gives the same grid whatever preceded it.
- Undo across the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied view and a restore all record a labelled entry, and a multi-cell paste is one entry rather than one per cell.
- Column permissions over four levels -
hidden,read,writeOnly,write- enforced consistently across editing, clipboard, export, sort, group, filter and saved state.writeOnlyis for secrets a user may set and never read. - Audit mode, showing a row’s before and after against a supplied snapshot with changed cells marked.
- An AI intent layer that describes the grid to a model of your choosing, validates the reply against the real columns and operators, and applies an approved plan as one undoable step. The grid makes no network call.
- Highlighting, on change or on demand, for a cell, a row or a column.
- CSV, Excel and clipboard export, and print.
Chrome
- A left action rail carrying undo, redo, export and restore, with the columns, filters, views and quick-filter panels as icons. (Extended in later releases - see 1.1.0 and 1.2.0.)
- Status bar, pagination, context menus, tooltips and an overlay layer.
Licensing
- One product, feature-identical everywhere. A licence removes the trial watermark; it does not unlock anything.
- Free on loopback hosts -
localhost,*.localhost,127.0.0.0/8,::1- so development needs no key. - Any other domain without a valid key renders in full and carries a trial watermark. Nothing ever refuses to render.
- Keys are checked locally with no network call, and name the domains they cover including wildcards.