Lattice Grid Buy a licence

developer guide

Columns, types and cell rendering

Defining columns, the built-in types and formatting, the cell renderers and charts, and the formatting rules that colour a cell by its value.

Developer guide › Columns, types and cell rendering

Defining columns

A column is an object with a field (the path to read from your data) and whatever else it needs. Everything except field or id is optional.

{ field: 'site.address.postcode', title: 'Postcode', type: 'text', width: 110 }

field reads a dot path, so nested data needs no flattening. The column's id defaults to the field, which is what you use everywhere else: in setCells, in filters, in saved state.

Grouped headers

Nest columns to get a spanning header row.

Two levels

columns: [
  { field: 'circuitId', title: 'Circuit' },
  { title: 'Location', children: [
    { field: 'region' },
    { field: 'country' },
    { field: 'site.address.postcode', title: 'Postcode' },
  ]},
]

Computed columns

A column can compute its value instead of reading one. Declare what it depends on and the grid builds a dependency graph, so editing cost invalidates margin and nothing else.

Derived values

{
  id: 'margin',
  title: 'Margin',
  type: 'number',
  format: 'currency:GBP:2',
  value: {
    deps: ['monthlyCharge', 'cost'],
    compute: (deps) => deps.monthlyCharge - deps.cost,
  },
}

// compute(deps, ctx): deps holds the resolved values of the columns you
// named; ctx carries { data, row, column, grid, context } when you need more.

compute is handed the values it declared rather than the whole row, which is what lets the grid memoise it: the same inputs give the same answer, so the result is cached until one of them changes. Reach for ctx.data when you genuinely need the rest of the row, and set pure: false if the result depends on something the grid cannot see.

Declaring deps is what makes this cheap. Without it the grid would have to assume any change might affect any computed column and recompute all of them on every edit. A cycle is caught at compile time with the full path named, rather than becoming a stack overflow at render time.

Sizing and pinning

Layout

{ field: 'circuitId', layout: { width: 130, pin: 'start' } }
{ field: 'notes',     layout: { flex: 1, min: 200 } }
{ field: 'isActive',  layout: { width: 90, pin: 'end' } }

Pinned columns are rendered in their own region and do not scroll horizontally. grid.columns.fit() distributes the viewport width across visible columns, and autoSize measures content.

Both are also on the column menu: Move left, Move right, Move to start, Move to end, and a Width submenu, and bound to the keyboard with a heading focused: Alt with a left or right arrow resizes, Shift with one moves the column. Neither operation depends on dragging.

A pinned region holds the edge of the viewport only while there is something to scroll. Where the columns are narrower than the grid: fixed widths, or a flex column that has reached its max: nothing scrolls, so the pinned columns sit directly after the centre ones and the spare width falls beyond them all, at the right of the grid.

To take that space up rather than leave it, give a column flex and no max, or call grid.columns.fit(). Note that removing a column’s width does not do it: a column with neither width nor flex takes the default 150 rather than a share of what is free. Absorbing space is what flex is for, and min only ever sets a floor.

Types and formatting

A type is not a label. It is a bundle of behaviour: how a value is formatted, parsed back from text, compared when sorting, stored in the columnar backing, written to Excel, and put on the clipboard. Setting type configures all of that at once.

FamilyTypes
Coretext, number, boolean, date, dateString, object, lookup
Temporaldatetime, time, duration
Networkipv4, ipv6, cidr, mac
Numeric baseshex, hex8, hex16, hex32, binary, binary8, octal
Units: computingbytes, megabytes, gigabytes, bitrate, gigabits
Units: physicalmetres, millimetres, kilometres, grams, kilograms, tonnes, seconds, milliseconds, hours
Units: engineeringspeed, kph, mph, knots, acceleration, area, hectares, volume, cubicMetres, energy, kilowattHours, power, kilowatts, force, pressure, bar, psi, torque, density, flow, litresPerMinute, radians, degrees
Units: electrical and scientificvoltage, current, resistance, capacitance, inductance, charge, conductance, fluxDensity, luminousFlux, illuminance, substance, absorbedDose, equivalentDose, radioactivity, luminousIntensity, doseRate, rpm, angularVelocity, ppm, ppb, basisPoints, molarity, massFlow, tonnesPerHour, viscosity, kinematicViscosity, thermalConductivity, specificHeat, frequency
Temperaturecelsius, fahrenheit, kelvin
Structuredjson, colour, rating, percent

Units

A unit type stores a plain number in a named base unit and makes only display and input unit-aware. That is the whole design: the column still backs onto a typed array, so sorting, filtering, grouping and totalling stay ordinary arithmetic and never touch the rendered text. A gigabyte column holding 0.5 shows 512 MB, accepts 512M typed over it, and stores 0.5 throughout.

Declared types, and one built to order

columns: [
  { field: 'span',     type: 'metres' },      // 1500 → "1.5 km"
  { field: 'load',     type: 'kilograms' },
  { field: 'inlet',    type: 'pressure' },     // 200000 → "200 kPa"
  { field: 'bearing',  type: 'degrees' },
  { field: 'cap',      type: 'capacitance' },  // 4.7e-11 → "47 pF"
  { field: 'inletTemp', type: 'celsius' },
],

// or configure your own base unit and precision
dataTypes: {
  runtime: createUnitType({ system: 'duration', unit: 'ms', display: 'auto' }),
}

Seventeen unit systems ship: data, bitrate, length, mass, duration, speed, acceleration, area, volume, energy, power, force, pressure, torque, density, flow and angle, plus fifteen SI-prefixed electrical and scientific quantities. registerUnitSystem adds one of your own.

Ambiguous units are refused, not guessed. A US gallon and an imperial gallon differ by about a fifth, and "ton" means three different masses. Each has its own symbol: gal (US), ton (UK), and the bare word is claimed by all of them, so typing it is rejected rather than resolved. A gal silently taken as US in a UK deployment is data corruption that reads as rounding.

The same rule catches case: mV and MV are a billion apart, so both exact spellings work and the case-folded mv is refused.

Customary units are accepted but never chosen. display: 'auto' walks the coherent SI ladder only. With the calorie, the BTU and the kilojoule all on one ladder, 4,000 J would render as 3.79 BTU: auto picks the largest unit that fits, and the BTU happens to be larger than the kilojoule. Ask for a BTU by name and you get one.

significantFigures renders to a fixed precision rather than a fixed number of decimals. Two decimals is four significant figures at 12.34 kB and three at 1.54 kB, so a column claims different accuracy row by row depending on nothing but which unit auto picked; significant figures are what an instrument has, and they hold across the ladder. Rounding is applied before the unit is chosen, so 999,999 bytes to three figures is 1.00 MB rather than 1,000 kB.

Angles wrap, so their mean is replaced. The average of 359° and 1° is 0°, and the arithmetic answer (180°) is a confident, plausible number pointing in exactly the wrong direction. A degrees or radians column averages by direction instead, and reports nothing where the angles cancel and there is no mean direction to give. The sum stays arithmetic, because a total rotation of 720° is two turns and that is a real figure.

Temperature is its own type, not a unit. Every other unit is a multiplication; Celsius to Fahrenheit carries an offset, and zero Celsius is not zero anything, so no factor converts it. celsius, fahrenheit and kelvin convert on input: type 72 F into a Celsius column and it stores 22.2, and refuse to be summed: twenty degrees plus twenty degrees is not forty degrees, and a footer saying so would be believed.

Types read from the data

A column that declares no type takes one from the rows. The first hundred non-empty values of that column are sampled, and a type is adopted only if every one of them matches it; anything mixed or ambiguous stays text and says so once on the console.

One key each, and the types arrive

columns: [
  { field: 'sku' },                          // text
  { field: 'quantity' },                     // number: aligned right, numeric filter
  { field: 'shipped' },                      // date
  { field: 'expedited' }                     // boolean: checkbox editor
]

Sampling happens once, when rows first arrive. A grid built empty and filled later infers on that first load, so fetching after construction is no reason to declare types you would otherwise leave out. Later loads keep the types already settled on: data that arrives tomorrow cannot change a column's type under a formatter or an editor that was configured around it.

Only the built-in names are ever inferred. Candidates are tried in registration order, so every string reaches text and every number reaches number before an extended type is considered. A column of IP addresses or durations is text until you name the type you want.

Inference never overrules a decision you made. An explicit type, a preset that carries one, a type in columnDefaults, and a lookup all win; type: false turns sampling off and keeps the column text whatever it holds. Set sampleSize to sample more or fewer than a hundred.

What declaring a type buys you, and what it does not. Sorting is not the reason. The default comparator is value-aware, so an undeclared column of numbers already sorts 2.5 before 10 rather than lexicographically, and an undeclared column of IPv4 addresses already orders correctly across 128.0.0.1.

What a type settles is everything around the sort: which editor opens, how typed text is parsed back into a value, how the value is formatted, which filter kind the header offers, how the column is stored, and what lands in Excel and on the clipboard. That is what inference supplies for free on a plain column, and what naming a type explicitly gives you where the data cannot say it, a duration, a byte count, a network address.

Dates are stored as strings, deliberately

A date column stores '2024-03-11', not a Date. This is the single most consequential type decision in the product.

The bug it avoids: new Date('2024-03-11') is midnight UTC. Render that in New York and it is the 10th. A user in London sets a delivery date, a colleague in Mumbai opens the same grid and sees the day before. Storing the wall-clock string means the date a user typed is the date everyone sees, because there is no instant to convert.

It is also faster and smaller: ISO 8601 sorts lexicographically in the same order it sorts chronologically, so a date column sorts as text, and repeated dates dictionary-encode well.

When you genuinely mean an instant (a log timestamp) use datetime and set format.timeZone.

Formats

Shorthand and full form

format: 'currency:GBP:2'                     // £1,234.50
format: 'percent:1'                          // 87.4%
format: 'date:dd MMM yyyy'                   // 11 Mar 2024

format: {                                    // when the shorthand runs out
  type: 'number',
  style: 'currency', currency: 'GBP', decimals: 2,
  negative: 'parentheses',                   // (£1,234.50)
  negativeClass: 'is-loss',
  nullDisplay: ', ',
}

Lookups

A lookup column stores an id and shows a label. Sorting, filtering, grouping, copying and exporting all use the label, because that is the thing the user is reasoning about, but the data keeps the id.

A status column

{
  field: 'statusId',
  title: 'Status',
  type: 'lookup',
  lookup: { options: [
    { id: 1, label: 'Open' },
    { id: 2, label: 'Pending' },
    { id: 3, label: 'Closed' },
    { id: 4, label: 'Escalated', variant: 'danger' },
  ]},
  cell: { decoration: 'pill' },
}

Options may be a function, may return a promise, and may be searched remotely with an AbortSignal so a superseded keystroke cancels its own request.

Custom types

A 32-bit register column

dataTypes: {
  reg32: LatticeGrid.createRadixType({
    radix: 2, bitWidth: 32, pad: true, signed: false, group: 8,
  }),
},
columns: [{ field: 'flags', type: 'reg32' }],

// 170 → 0b00000000 00000000 00000000 10101010

radix is 2, 8 or 16, or the names binary, octal and hex. Base 10 is not among them and is not an oversight: a decimal number is what the number type is for, with grouping, decimals, currency and notation that a radix formatter has no concept of. Passing radix: 10 names the supported set in the console and falls back to hex rather than producing something that looks like a number column but is not one.

Cells and renderers

By default a cell writes text. When you want more, cell takes a decoration, a named renderer, a template or a component.

Decorations, the common cases, without writing a renderer

cell: { decoration: 'pill' }                     // a status chip
cell: { decoration: 'bar', min: 0, max: 1 }      // an inline bar
cell: { decoration: 'heat', ramp: 'redGreen' }   // a heat fill
cell: { decoration: 'dot' }                      // a leading dot

Variants: mapping a value to a semantic colour

cell: {
  decoration: 'pill',
  variant: { when: [
    { op: 'eq', value: 'Escalated', use: 'danger' },
    { op: 'eq', value: 'Pending',   use: 'warning' },
  ], default: 'success' },
}

Variants are semantic tokens rather than colours: danger, not #c22b2b. The theme decides what danger looks like, and it looks the same in the status pill, the filter chip and the validation message. Changing the palette is one custom property, not a search for hex codes.

Your own renderer

components: {
  sparkline: {
    render(el, p) { el.appendChild(draw(p.value)); },
    refresh(el, p) { update(el, p.value); return true; },
    release(el) { el.textContent = ''; },
  },
},
columns: [{ field: 'history', cell: { render: 'sparkline' } }],

refresh returning true is the contract that makes recycling work: it means "I updated in place, keep this element". Return false and the grid rebuilds the cell. A renderer that only implements render still works, it is just rebuilt on every reuse.

Templates, and what allowUnsafeTemplates permits

cell: { template: '<span class="sku">{{ value }}</span>' }   // escaped

// Raw interpolation needs the grid-level opt-in:
allowUnsafeTemplates: true,
cell: { template: '<span>{{{ value }}}</span>' }

The flag permits markup, not code. Without it, {{ }} escapes and a {{{ }}} segment is refused outright. With it, an interpolated value may carry presentational markup: <b>, <a href>, a <span>, and everything executable is still stripped from it: <script>, <iframe>, <style> and the other code-bearing tags, every on* handler attribute, and javascript: or data: URLs including entity-encoded spellings of them. The same rules apply to a string returned from cell.render, which is the same gate.

Why the value is treated differently from the template. You wrote the template and can audit it; the value is row data and usually arrives from somewhere you cannot. A template is refused at compile time for a dangerous tag, but that refusal says nothing about what a value interpolated into it might contain.

It is a narrow allowance, not a sanitiser. It exists so a grid cell can show emphasis and a link. To render arbitrary third-party HTML, sanitise it yourself and return an element from cell.render.

Image columns

A column whose value is a URL, drawn as a picture. Avatars beside a name, product thumbnails, company logos.

A circular avatar

{ field: 'avatar', type: 'image', layout: { width: 72 },
  cell: { props: { shape: 'circle' } } }
PropTypeDescription
shape'rounded' | 'circle' | 'square'Defaults to rounded. circle is the avatar case.
sizenumberPixels. Omit and the box follows the density scale at 0.68 of the row height.
fit'cover' | 'contain'cover by default, so a mixed set of aspect ratios still forms a tidy column.
altstring | functionAlternative text. Defaults to the cell's formatted text.
loading'lazy' | 'eager'lazy by default.

Only image URLs load. http, https, blob: and data:image/ are permitted; everything else is refused, including a data: URL claiming to be anything other than an image. Relative URLs pass, since they cannot name a scheme. A grid drawing URLs that arrived in a data feed is exactly where a bad one gets through. Note this differs from the link renderer, which refuses data: outright: correct for an anchor, wrong for a picture.

A missing or broken image never moves the column. The box is sized from the row height whether or not the picture loads, and a failed load leaves it empty rather than showing the browser's broken-image glyph, which is a different size in every engine.

Exports carry the URL. CSV, Excel and the clipboard all get the text, not markup. Values are dictionary-encoded, which pays unusually well here: one URL per user or per company repeats down the whole column.

Two-line cells

A bold primary line over a quieter secondary one, taken from a second property of the same row. A name over an email, a title over a category, a company over its sector.

Naming the second line

{ field: 'name', cell: { render: 'twoline', props: { secondary: 'email' } } }

// a dot path reaches a nested field without a callback
{ field: 'name', cell: { render: 'twoline', props: { secondary: 'contact.email' } } }

// a function for anything the row does not already hold
{ field: 'name', cell: { render: 'twoline', props: {
    secondary: (p) => `${p.data.city}, ${p.data.country}`,
} } }

// `format` decorates whatever `secondary` produced
props: { secondary: 'user', format: (v) => v ? `User: ${v}` : '' }

It needs the room. Two lines do not fit in a 28px row. Pair it with density: 'comfortable' or 'spacious', or a rowHeight of 40 or more. Nothing stops you using it in a shorter row; the second line is simply clipped.

The second line reads the row's data, not another column. It is usually a field nobody wants a column of, and requiring one would mean declaring a column purely to hide it. A path that does not resolve leaves the line empty rather than throwing, and a cell with no second line collapses to one centred line rather than leaving a gap.

Both lines truncate; neither wraps. A wrapped second line would change the row height, and in a fixed-height grid that means being cut off mid-descender instead. The accessible name carries both lines as one string, so a screen reader gets the half of the cell that disambiguates the first.

Cards, lists and feeds

rowTemplate draws each row with a layout of your own instead of dividing it into columns. A card list, a feed, a search-result list, a message list: any presentation where a record is a small piece of layout rather than a line of cells.

A card list

createGrid(element, {
  columns,                       // still declared: they are the data model
  rows,
  rowKey: 'id',
  rowHeight: 64,
  rowTemplate: '<p class="title">{{data.name}}</p>'
             + '<p class="sub">{{data.owner}} · {{data.stage}}</p>',
});

The template compiles; it does not call back. There is deliberately no "here is a container, build what you like for this row" hook. That shape is easy to offer and would be used to allocate DOM per row, and at that moment the virtualisation stops paying for itself: quietly, and in a way nobody can attribute to a change. A row template is the same declarative string a cell template is: parsed once, built into real DOM the first time an element is used, and afterwards updated by writing text into the few nodes the bindings own. Scrolling ten thousand records through a hundred pooled cards allocates nothing.

Everything underneath is unchanged. Sorting, filtering, grouping, selection, permissions, redaction, saved views, undo, export and the remote source all apply exactly as they do to a table: only the drawing changes. That is the reason to build a card view on a grid rather than beside one.

A card is still a row. It carries the same row identity a table row does, so row:clicked and row:dblclicked fire with the same payload, clicking selects, the context menu opens, and rowReorder works, with the card itself as the drag handle, since there is no cell to put a grip in. None of that is a second implementation; it is the same code that serves a table.

It is announced as a list, not a grid. A card has no columns, so the grid role, which promises columns, gridcell children and a two-dimensional keyboard model: would misdescribe it completely. The layer is a list, each card a listitem carrying its position and the size of the whole set, and the column header is not drawn. role and itemRole override both, for a presentation that is really a listbox.

Collapsing to cards on a narrow screen

A table on a phone is a compromise however it is styled. responsive presents rows as cards when the grid is too narrow to be a table honestly, and returns to a table above the threshold.

One grid, two presentations, chosen by width

createGrid(element, {
  columns, rows, rowKey: 'id',
  toolPanel: true,
  responsive: {
    maxWidth: 640,                // the default
    rowHeight: 64,
    template: '<p class="title">{{cell.name}}</p><p>{{cell.owner}}</p>',
  },
});

Measured on the container, not the viewport. A media query is the obvious way and the wrong one: a grid inside a 400px panel on a large monitor is narrow, and a grid filling a small tablet is not. The grid already watches its own element for size changes, so the same observer answers this.

The state a user built survives the switch. Rotating a phone must not lose the sort, the filters, the selection or the scroll position, and it does not, it is one grid throughout, and only the drawing changes. An open cell editor is closed, since the cell it belonged to stops existing.

Sorting and filtering need a home when there are no column headings to click, and the tool panel is it: set toolPanel: true and its rail stays available in card presentation with the columns and filter panels behind it. Export is unaffected: the columns are still the data model, so a CSV or an Excel file from a collapsed grid holds every column, including ones the card does not show.

A collapsed card takes rowHeight from the responsive block rather than the grid's, because a table row is too short to hold one; 64px by default. The change is announced, and the role tree follows the presentation. presentation:changed fires with 'cards' or 'table'.

Showing what the grid shows

{{cell.column}} is the text the table puts in that cell, the column's own formatter, data type, number and date settings and lookup label, all of it. {{data.field}} is the raw value underneath.

BindingReads
{{cell.value}}£1,250.50, the cell's rendered text
{{data.value}}1250.5, the stored number
{{cell.stage}}Held, a lookup's label
{{data.stage}}2, the lookup's id

Both are wanted, which is why both exist: a card showing a value to a person wants cell, and a template comparing or calculating wants data. A lookup is the case that decides it, a card showing 2 where the table shows Held is not a formatting preference but a plain bug.

A protected column cannot be read raw. Binding {{data.password}} on a secret or redacted column would print the value the column exists to hide, while the table beside it shows dots. Such a binding reads the masked text instead and says once that it did, a card must not become the hole a redaction closes.

Several cards on a line

By default a card takes a line to itself, which is what a feed or a search-result list wants. A gallery wants tiles, and there are two ways to ask for them because they answer different questions.

A fixed count, and a size that flows

rowTemplate: { template: CARD, cardsPerRow: 3 }        // always three across
rowTemplate: { template: CARD, maxCardWidth: 260 }    // as many as fit

cardsPerRow is a count, for a layout that must not reflow. maxCardWidth is a ceiling: the grid fits as many whole cards as it can without exceeding it, and they share the remaining space rather than leaving a ragged margin, so 900px at a 200px ceiling is four cards of 225px, and the count changes with the container. gap sets the space between them. Where both are given, cardsPerRow wins, being an instruction rather than a preference.

The scroll height counts lines, not records. Four records on a line means the content is a quarter as tall as the row model alone would make it, and a scrollbar sized per record would be four times too long, the last several screens empty. The tiled layout works out its own window from the scroll position for the same reason: the window it would otherwise be handed counts one record per line and would leave the bottom of the screen bare. Pooling is unaffected; scrolling a tiled gallery reuses its elements exactly as a list does.

Tiles are a fixed height, taken from rowHeight, rowHeight: 'auto' measures a rendered row and cannot describe a line holding several of different heights. Variable-height tiles flowing into the shortest column is a masonry layout, which is a different thing and is not offered.

Row heights work as they do everywhere else, including rowHeight: 'auto', which measures the rendered card: content-driven card heights need no extra configuration. The columns are still declared and still hold the data: they are what sorting, filtering and export operate on, and what the bindings read.

Full-width rows

A row drawn as a single band across every column instead of being divided into them: a section banner, an explanatory note, an empty-group message, a “load more” affordance, anything that belongs between rows and is not itself divided by the columns.

A banner before each section

createGrid(element, {
  columns,
  rows,   // your data, with the banners in it
  fullWidth: {
    when: (row) => row.data.kind === 'section',
    render: ({ data }) => data.title,
  },
});

render returns a string for text or a node for content, or returns nothing and writes into params.element itself. An HTML string is deliberately not accepted. params carries { row, data, index, grid, element }.

The band holds still while the columns scroll under it, which is what a banner is for: text that scrolled sideways out of view with the columns would be a worse version of a cell. It is drawn over the pinned regions as well as the centre, so it genuinely spans every column.

A full-width row is still one of your data rows. It is counted by rows.count(), sorted, filtered and exported like any other; only its presentation changes. That is the difference between this and pinned rows, and it is the thing to get straight before choosing between them: full-width changes how a row looks, pinned changes whether a row is data at all.

So if your banners must not appear in an export or a row count, they should not be in the data. If they are section headings that belong with the records they head, and should sort, filter and export alongside them: this is the right tool.

One consequence worth stating plainly: sorting reorders banners along with everything else, because the predicate follows the row and not its position. Either do not offer sorting on such a grid, or sort on a key that keeps each section together.

A band is exposed as a row containing one cell with aria-colspan covering every column, so a screen reader reads it as one wide cell rather than as a row with missing ones. No second, empty row is rendered underneath it.

In-cell charts

Eleven chart types for a cell: line, area, column, winloss, pie, donut, bullet, stacked, range, gauge and delta. Most read an array; bullet and gauge read a number.

A trend, a mix and a target

columns: [
  { 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 } } },

  // Two views of one field: give each an explicit id.
  { id: 'shape',  field: 'readings', cell: 'line' },
  { id: 'detail', field: 'readings', cell: 'column' },
]

A cell is a few hundred pixels seen for a second, so there are no axes, no gridlines and no legend. The chart carries one idea (a shape, a share, a comparison) and the number beside it carries the precision. Hide the number with label: false when the column next to it already says the same thing.

Pin min and max when columns are meant to compare. A sparkline scaled to its own data fills its cell whatever the magnitude, so two rows differing by an order of magnitude draw identically. Pinning the scale is what makes the column readable down its length rather than only across it.

A gap is not a zero. Entries that are not numbers break the line and omit the bar, rather than being drawn at the baseline: joining across a missing reading would draw a trend nobody measured, and drawing it at zero invents a dip.

Cost. Each chart is one SVG built once, with the paths' d attributes the only thing a repaint writes, and a bar chart is two paths rather than one element per bar. Rows recycle as you scroll, so this is what keeps a chart column the same price as a text one. Nothing measures the DOM, the drawing happens in a fixed coordinate space that CSS scales.

Accessibility. The chart is aria-hidden and the cell carries a summary: "12 points, 9 to 20, ending 18". A path cannot be read aloud, and a series announced value by value tells a listener less than the sentence does.

Conditional formatting

A rule is a condition and the styling it produces. compileRules() turns a list into the function cell.style already accepts.

import { compileRules } from '@toclocoinc/lattice-grid';

{ field: 'margin', cell: { style: compileRules([
  { when: { op: 'lt', value: 0 }, style: { background: '#fdecea', colour: '#b91c1c' } },
  { when: { op: 'gt', value: 20 }, style: { weight: 600 }, stopIfTrue: false },
  { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
]) } }

The operators are the filter's operators. gt, between and contains mean here exactly what they mean in the grid's filters. Anyone who has built a filter has already learned this, and two vocabularies for one idea is how a product ends up explaining itself twice.

First match wins, by default. "Red if overdue, amber if due this week" reads top to bottom and stops, the spreadsheet convention, and the one people expect. stopIfTrue: false lets rules combine: weight from one, colour from another.

A blank cell satisfies no comparison. Number(null) is zero, so a naive implementation sweeps every empty cell into "less than 100" and formats half a column that has no data in it.

Text still compares numerically. A rules panel produces strings, the box the user typed into yields "100", not 100, and comparing those as text puts "9" above "100".

A scale's bounds are given, not derived. Deriving them means scanning the column per cell, and a scale that rescaled as rows were filtered would change a cell's colour without its value changing, the opposite of what the colour is for.

Formatting an end user can change

compileRules() above compiles at configuration time into cell.style, which you write and a user cannot reach. grid.formatting holds the same rules as runtime state instead: the cell layer asks it on every paint, so a rule added while the grid is running takes effect on the next frame.

Rules as state

grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } });
grid.formatting.add('*',      { when: { op: 'blank' },        style: { background: '#f1f3f5' } });

grid.formatting.list('margin');        // [{ id, when, style }, …] in evaluation order
grid.formatting.move('margin', id, 0); // order decides which rule wins
grid.formatting.update('margin', id, { enabled: false });
grid.formatting.clear('margin');

Two scopes, one ordered list. A rule sits on a column id or on '*' for every column. Evaluation joins them: grid-wide first, then the column's own, so a column rule can override a grid-wide one, and stopIfTrue means the same thing across the join as it does within either half.

Saved views and undo came free. The rules are a section of GridState, and both saved views and the undo timeline are built on that. Nothing in the formatting model knows either exists.

Rules must be JSON. style cannot be a function here, because the rules are serialised into views and undo slices. Config-time cell.style still takes one, which is the right home for a rule a user should not be able to change.

Both paths coexist. Where a column has a cell.style and a runtime rule matches, the two are merged and written once; the runtime rule wins for the properties it names and leaves the rest of your styling alone.

Group rows are not formatted. A group row summarises many values rather than being an instance of one, which is the same reason decoration is dropped for it.

The panel

createGrid(el, { toolPanel: { side: 'right', panels: ['columns', 'filters', 'formatting'] } });

The panel is a form over that array and nothing more: every control is one call into grid.formatting, which is what makes each gesture undoable without the panel knowing undo exists. It exposes ordering because ordering is meaning: dragging a rule up can change which of two colours a cell takes.

Icon sets and data bars are column decorations rather than cell styles, the bar and icon decorations already render them, and driving those from the panel needs a runtime column-decoration API that persists and undoes alongside the rules. Building it as a second bar implementation inside the rule engine was the alternative, and the wrong one.

Highlighting

One mechanism for two jobs: the flash a changed cell makes, and a marker you paint deliberately.

On change, and on demand

highlightOnChange: { colour: '#ffe08a', duration: 1200 },

grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
grid.highlight({ key: 'r3' },      { colour: '#fdeaea', duration: 0 });  // until cleared
grid.highlight({ colId: 'margin' },{ colour: '#e7f1fd', duration: 0 });
grid.highlight.clear({ key: 'r3' });
grid.highlight.clear();

Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over it. A highlight belongs to the row rather than the element, so it survives scrolling, sorting and paging.

Column tags

Tag columns, then let a user show only the ones carrying a chosen tag. Sixty columns of monthly figures across five years become twelve by picking a year.

Five years of months, filtered to one

createGrid(element, {
  columns: [
    { field: 'account', title: 'Account' },              // no tags
    { field: 'jan24', title: '01/24', tags: ['2024', 'Q1'] },
    { field: 'feb24', title: '02/24', tags: ['2024', 'Q1'] },
    // …
    { field: 'total', title: 'Total' },                  // no tags
  ],
  rows,
  columnTagFilter: true,
});

Only tagged columns are ever hidden. That is the rule the whole feature turns on. A financial grid with sixty month columns also has an account name, a total and a variance, and none of those belong to a year: if filtering hid them the view would be useless, and tagging every column merely to keep it visible would be busywork. So "show 2024" does not mean "hide everything else"; it means "hide tagged columns that are not 2024".

The same rule runs the other way: an untagged column you hid yourself stays hidden, because forcing it visible would undo a decision that has nothing to do with tags.

A column can carry more than one tag, which gives you a second axis for free: tag each month with its year and its quarter, and a user can pick either. The dropdown lists tags in the order they were declared rather than alphabetically, since they are usually already in a meaningful sequence and sorting would put Q10 before Q2.

MemberDoes
columns.tags()Every distinct tag, in declaration order.
columns.showTagged(tags)Show only the columns carrying one of these. Nothing, or an empty list, shows all. Returns the ids it hid.
columns.activeTags()What is being shown, empty when all are.
columns:taggedFired with { tags, hidden }.

Headings without the controls

A dense grid often wants the heading and nothing else. showColumnFunctions: false leaves each 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. Sorting, filtering and the column menu stay reachable through the API, the keyboard and the tool panel; only the furniture goes. The resize grip stays, since dragging a column wider is a view adjustment rather than a function of the column.

Aligned grids

Two or more grids that read as one table split into sections: a summary band above a detail grid, two datasets side by side under identical columns, a frozen top section that is genuinely different data rather than a pinned row.

A summary band above a detail grid

const summary = createGrid(top, { columns, rows: totals });
const detail  = createGrid(bottom, { columns, rows, alignedGrids: [summary] });

Declare it on the grid you create last, since that is the only one that can name the others. The link is peer-based once made: whichever grid the user resizes is the one the others follow.

SharedIndependent
Column widths and flexSort
Column orderFilters
Column visibilitySelection
PinningGrouping and totals
Horizontal scrollVertical scroll, and the rows themselves

What is not shared is the design. If sort, filters and selection travelled too, this would not be a feature, it would be one grid with extra steps. The reason to have two is that the sections hold different data, so each keeps its own view of it.

Vertical scroll stays independent for the same reason: the grids hold different numbers of rows, and yoking them would make the shorter one run out.

A column one grid has and another does not is skipped rather than invented, and nothing checks that the column sets match: aligning grids with different columns is a caller error that produces a visibly wrong result rather than a silent one. Destroying any grid releases its link and leaves the rest working.