Custom Features
A feature is a plain object with optional hooks. The nine that ship use nothing that is not available to yours, so anything they can do, your own can do the same way.
cellDecoration can carry style now, for the values a class cannot name, and cellValue stands between a cell and every way its value leaves the grid: the cell, CSV, the clipboard, the
quick filter's text, a set filter's list and an editor's draft.The Shape
Only id is required, and a feature that defines one hook costs one hook. The two grids below are the
same rows and the same columns. The second registers a feature of two lines, an id and a cellDecoration, and that is the whole difference.
import type { GridFeature } from '@sv5ui/datagrid';
// A feature is a plain object. Every hook is optional, and the built-in
// features use nothing that is not available here.
const myFeature = <TRow,>(): GridFeature<TRow> => ({
id: 'my-feature', // also the key of its state on grid.state
pipelineStage: { order, transform }, // an ordered, pure row transform
createState: (grid) => new MyState(grid),
createApi: (grid) => ({ myMethod: () => {} }),
keybindings: [{ key: 'Ctrl+k', when, handler }],
menuItems: (ctx) => [],
cellDecoration: (ctx) => ({ class: '', selected: false }),
serialize: (grid) => sliceForSnapshot,
hydrate: (slice, grid) => {}
});Decorating Cells
Six lines, and salaries over 120,000 are picked out wherever they are. This is where a feature paints, and it is asked per rendered cell, so it wants to be cheap.
import type { GridFeature } from '@sv5ui/datagrid';
// The smallest useful feature: a class on the cells that deserve one.
const highlightHighEarners = (): GridFeature<Person> => ({
id: 'highlight-high-earners',
cellDecoration: ({ node, column }) =>
column.id === 'salary' && node.row.salary > 120000
? { class: 'bg-success/10 font-semibold text-success' }
: undefined
});
createDataGrid<Person>({ columns, data, getRowId, features: [highlightHighEarners()] });
// cellDecoration runs for every rendered cell, so keep it cheap. A grid
// whose features do not define it skips the work entirely.Styling From the Value Itself
A class can only name a shade someone thought of in advance. When the value is the scale, the
decoration carries style instead: a record keyed by CSS property, custom properties included, which is how a feature
reaches a pseudo-element. The Salary column below is tinted by how large it is rather than by
which of three buckets it falls in.
// A class can only name a value someone thought of in advance. The scale
// below is the number itself, so it goes on the cell's own style.
const heat = (max: number): GridFeature<Person> => ({
id: 'heat',
cellDecoration: ({ node, column }) =>
column.id === 'salary'
? {
style: {
'background-color': `color-mix(in oklab, var(--color-primary) ${Math.round(
(node.row.salary / max) * 45
)}%, transparent)`
}
}
: undefined
});
// Keyed by CSS property, custom properties included, which is how a
// feature reaches a pseudo-element:
{ style: { '--dg-bar': '62%' } }
// Several features decorating one cell merge per property, the later one
// winning, the same way their classes stack. A key that is not a CSS
// property is dropped and a value is cut at the first semicolon, so one
// entry stays one declaration and a colour read out of row data cannot
// open a second.
// The grid writes its own layout as style directives, which outrank the
// attribute: a decoration can paint a cell, but it cannot move it out of
// its column or unpin it.Several features decorating one cell merge per property, the later one winning, the same way their classes stack. A key that is not a CSS property is dropped and a value is cut at the first semicolon, so one entry stays one declaration and a colour read out of row data cannot open a second. The grid writes its own layout as style directives, which outrank the attribute, so a decoration can paint a cell but cannot move it out of its column or unpin it.
Gating a Cell's Value
A decoration paints a cell. cellValue decides what the value is on the way out, and it covers every way out at once: the cell
and its tooltip, CSV, the clipboard, the text a quick filter searches, the list a set filter offers,
and the draft an editor opens with. Masking what is drawn and nothing else is the mistake it exists
to prevent.
The hook is asked per column and per purpose rather than per value, so a pass that reads a
whole column asks once and then loops. Turn the switch below and watch all six purposes
change together; the table under the grid is read from the grid itself with grid.getValue(node, column, purpose).
| Purpose | What the first row's Salary looks like |
|---|---|
| render | *** |
| export | *** |
| clipboard | *** |
| search | *** |
| facet | *** |
| edit | *** |
Salary is editable in this grid. Double-click it with the mask on and nothing opens: an editor seeded with a substitute would commit it over the real data.
// cellValue stands between a cell and every way the grid lets it out.
const maskedReader = () => '***';
const maskSalary = (masked: () => boolean): GridFeature<Person> => ({
id: 'mask-salary',
// Asked per column and per purpose, not per value: the passes that read
// a whole column at a time ask once and then loop over the rows.
cellValue: ({ column }) => (column.id === 'salary' && masked() ? maskedReader : undefined)
});
// The purposes are every way out: 'render' | 'export' | 'clipboard' |
// 'search' | 'facet' | 'edit'. Masking render alone would leave the value
// in the clipboard, in the CSV and in the text a quick filter searches.
// Read one yourself when you need to know what a purpose sees:
grid.getValue(node, column); // what the cell draws
grid.getValue(node, column, 'export'); // what a CSV would carry
grid.readerFor('salary', 'export'); // the reader itself, to hoist out of a loop
// Hand the same reader back each time. The grid compares by identity to
// tell a substituted cell from an untouched one, and the quick filter's
// text and the set filter's value list are cached per column and keyed by
// the reader: a fresh closure per call throws both caches away.Hand the same reader back each time. The grid compares by identity to tell a substituted cell from an untouched one, and the quick filter's text and the set filter's value list are held per column and keyed by the reader, so a fresh closure per call throws both caches away. The library measures that at 5ms against 71ms over a hundred thousand rows.
// Answer in the type the column draws. A built-in renderer formats what
// it is handed, so '***' on a currency column parses as no number and the
// cell draws empty; null draws the column's empty text. A mark of your own
// needs an untyped column or a cell snippet.
// A cell the reader substitutes is one the grid refuses to edit: an editor
// opened on a value the user is not being shown would commit the
// substitute over the real data.
// Two things it deliberately does not cover:
{ id: 'salary', sortable: true, filter: 'number' } // take both off a masked column
// Sorting reads n log n times and stays on the raw value, so a masked
// column can still be ordered by what it hides. A filter predicate decides
// which rows survive and stays raw for the same reason, so a narrowing
// filter plus a row count says something about what was hidden.
// And the row object itself still reaches your own cell snippet, cellClass
// and tooltip. This is a gate on the grid's own output, not a security
// boundary: data that must not reach the browser should not be sent to it.Adding a Pipeline Stage
A stage decides which rows exist downstream of it. This one sits just after the filter stage and reads a rune, which is what makes the grid re-run it when the switch moves.
import { PIPELINE_ORDER, type GridFeature } from '@sv5ui/datagrid';
// A stage is a pure transform of RowNode[], inserted at a declared order:
// filter 100, sort 200, group 300, flatten 400, pin-split 500, window 900
const onlyActive = (enabled: () => boolean): GridFeature<Person> => ({
id: 'only-active',
pipelineStage: {
order: PIPELINE_ORDER.filter + 1, // just after the filter stage
transform: (nodes) =>
enabled() ? nodes.filter((node) => node.row.status === 'active') : nodes
}
});
// Reading a rune inside transform is what makes the stage re-run: the
// pipeline memoizes each link with $derived, so an unchanged input is an
// unchanged output.State, Menus and Keys
The feature below owns a set of flagged rows, paints them, contributes a context-menu entry
and binds a key. Right-click a row and choose Toggle flag, or focus a row and press Ctrl+F.
import { type GridFeature, type GridState } from '@sv5ui/datagrid';
const FLAGS = 'flags';
class Flags<TRow> {
flagged = $state.raw<ReadonlySet<string>>(new Set());
constructor(private grid: GridState<TRow>) {}
toggle = (id: string) => {
const next = new Set(this.flagged);
next.has(id) ? next.delete(id) : next.add(id);
this.flagged = next;
};
}
// Declare what the feature adds to grid.api, from the feature's own module.
declare module '@sv5ui/datagrid' {
interface GridApi {
toggleFlag?: (id: string) => void;
}
}
export const flags = <TRow,>(): GridFeature<TRow> => ({
id: FLAGS,
createState: (grid) => new Flags(grid),
createApi: (grid) => ({ toggleFlag: getFlags(grid)!.toggle }),
cellDecoration: ({ grid, node }) =>
getFlags(grid)?.flagged.has(node.id) ? { class: 'bg-warning/12' } : undefined
});
// The accessor is the typed path, the same shape the built-ins ship.
export const getFlags = <TRow,>(grid: GridState<TRow>) =>
grid.feature<Flags<TRow>>(FLAGS);Menu Items and Keybindings
A when that
returns false lets a later binding claim the same key, which is how two features can share one
shortcut without knowing about each other. The lookup takes the first binding whose key matches
and whose guard passes, in registration order.
This grid registers two features, both on Ctrl+e.
The first only wants the Salary column. Click a Salary cell and press it, then click a Name
cell and press it again: the same key reaches a different feature.
Nothing yet. Click a cell, then press Ctrl+e.
const flags = <TRow,>(): GridFeature<TRow> => ({
id: 'flags',
// Context menu entries. ctx carries the grid, and the row or column the
// menu was opened on.
menuItems: ({ grid, node }) =>
node
? [
{
id: 'toggle-flag',
label: 'Toggle flag',
icon: 'lucide:flag',
onSelect: () => getFlags(grid)?.toggle(node.id)
}
]
: [],
// Keybindings, with a guard. Returning false from when lets a later
// binding claim the same key.
keybindings: [
{
key: 'Ctrl+f',
when: (grid) => grid.focus.active.row >= 0,
handler: (grid) => {
const node = grid.preWindowNodes[grid.focus.active.row];
if (node) getFlags(grid)?.toggle(node.id);
}
}
]
});Joining the Snapshot
Add serialize and hydrate to the flags feature and the flags travel with the layout. Features are keyed by id inside the
snapshot, so one added after a snapshot was written starts fresh rather than reading someone else's
slice.
Right-click a row and toggle a flag or two, save, clear them, then restore. The flags come back, and you can see them in the snapshot under their feature id.
const flags = <TRow,>(): GridFeature<TRow> => ({
id: 'flags',
// The feature's slice of a state snapshot. Return undefined and it stays
// out of the snapshot entirely.
serialize: (grid) => {
const ids = [...(getFlags(grid)?.flagged ?? [])];
return ids.length > 0 ? ids : undefined;
},
// Restores what serialize produced. A feature added after the snapshot
// was written simply starts fresh.
hydrate: (slice, grid) => {
const state = getFlags(grid);
if (state && Array.isArray(slice)) state.flagged = new Set(slice as string[]);
}
});
// Both run through grid.api.getState() and setState(), and therefore
// through persistState too.GridFeature Hooks
Every hook is optional except id.
| Hook | What it does |
|---|---|
id | Unique feature id, and the key of its state on grid.state |
pipelineStage | An ordered, pure transform of the row list |
createState | Reactive state the feature owns, reached through grid.feature(id) |
createApi | Imperative methods merged into grid.api |
keybindings | Key, an optional when guard, and a handler |
menuItems | Column and context menu entries. ctx carries the grid, and the row or column |
cellDecoration | Per-cell classes and aria-selected. Runs per rendered cell |
serialize | The feature's slice of a snapshot. undefined stays out |
hydrate | Restores what serialize produced |
PIPELINE_ORDER
The orders the built-in stages occupy. Pick a number beside one to sit next to it.
| Constant | Order | Occupied by |
|---|---|---|
PIPELINE_ORDER.filter | 100 | Where filtering() sits |
PIPELINE_ORDER.sort | 200 | Where sorting() sits |
PIPELINE_ORDER.group | 300 | Reserved for grouping |
PIPELINE_ORDER.flatten | 400 | Where a tree becomes a list |
PIPELINE_ORDER.pinSplit | 500 | Where rowPinning() lifts its rows out |
PIPELINE_ORDER.window | 900 | pagination() or virtualization(), never both |