Headless Core
createDataGrid returns state and a derived pipeline, and nothing else. No DOM, no styling, no component to mount.
The component layer is one consumer of it, and your own markup can be another.
Creating a Grid
Reading the grid is reading runes, so a derived value tracks it without a subscription and
without a store. The three badges below are three $derived lines over the same instance. Sort a column, or add a row, and they follow.
data is $state.raw, so the button assigns a new array rather than pushing onto the old one. A push would
change nothing on screen.
import { createDataGrid, filtering, sorting } from '@sv5ui/datagrid';
// No component involved: this is state and a derived pipeline.
const grid = createDataGrid<Person>({
columns,
data: people,
getRowId: (person) => String(person.id),
features: [sorting(), filtering()]
});
// Reading it is reading runes, so a derived value tracks it
let count = $derived(grid.totalRows);
let firstOnScreen = $derived(grid.nodes[0]?.row.name);Without the Component Layer
The list below is a plain ul. It
has no grid markup, no CSS from the package and no DataGrid component, yet filtering, sorting and paging all work, because those are pipeline stages rather
than UI.
- Hoang Kowalski Design
- Bruno Nguyen Growth
- Bruno Dubois Design
- Farid Haddad Growth
- Jonas Yilmaz Core
<script lang="ts">
// The core does not need the component layer at all. Here the same grid
// drives a plain list: filtering, sorting and paging still work, and
// nothing about the DOM comes from the package.
const grid = createDataGrid<Person>({
columns,
data: people,
getRowId,
features: [sorting(), filtering(), pagination({ pageSize: 5 })]
});
</script>
<input oninput={(e) => getFiltering(grid)?.setQuickFilter(e.currentTarget.value)} />
<ul>
{#each grid.nodes as node (node.id)}
<li>{node.row.name} - {node.row.team}</li>
{/each}
</ul>
<button onclick={() => getPagination(grid)?.setPage(2)}>Page 2</button>The Row Pipeline
A RowNode is { id, row, index, meta? }: the raw row, its identity and its position. The grid exposes three points along the
pipeline, and the numbers below are those three. Filter the list and page it to watch them
come apart.
// Four points along the chain, each a rune
grid.sourceNodes; // before filtering
grid.preWindowNodes; // filtered and sorted, before paging or virtualizing
grid.nodes; // exactly what is on screen
grid.totalRows; // preWindowNodes.length
// A RowNode is the unit the pipeline moves
// { id, row, index, meta? }
for (const node of grid.preWindowNodes) {
console.log(node.id, node.index, node.row.name);
}Composing the Chrome
Grid.Root sets the context the other parts read, so it wraps them all. Beyond that nothing is mandatory:
leave a part out and it is simply not there, put your own markup between them and it stays.
<script lang="ts">
import { Grid, createDataGrid } from '@sv5ui/datagrid';
const grid = createDataGrid<Person>({ data, columns, getRowId, features });
</script>
<!-- Root sets the context every other part reads, so it wraps them all.
Nothing else is mandatory: drop a part and it is simply not there. -->
<Grid.Root {grid} class="space-y-3">
<Grid.Toolbar>
<Grid.QuickFilter placeholder="Search" debounce={300} />
<Grid.FilterChips />
<div class="ms-auto flex gap-2">
<Grid.ExportMenu filename="rows.csv" />
<Grid.ColumnChooser />
<Grid.DensityToggle />
</div>
</Grid.Toolbar>
<Grid.ContextMenu>
<Grid.Viewport class="h-[420px]">
<Grid.Header />
<Grid.Body emptyText="Nothing here" />
</Grid.Viewport>
</Grid.ContextMenu>
<Grid.StatusBar />
<Grid.Pagination pageSizes={[5, 10, 25]} />
</Grid.Root>Events
Fifteen entries, each with its own payload shape. A handler is typed from the name alone, so the payload needs no annotation. The Quick Start page logs them live against a grid you can drive.
| Event | Payload |
|---|---|
sortChanged | { sort: SortState[] } |
filterChanged | { filter: FilterModel } |
pageChanged | { page: number, pageSize: number | null } |
rowCountChanged | { total: number } |
columnResized | { columnId: string, width: number } |
columnMoved | { columnId: string, toIndex: number } |
columnPinned | { columnId: string, side: PinnedSide | null } |
columnVisibilityChanged | { columnId: string, hidden: boolean } |
selectionChanged | { selectedIds: string[] } |
rowsCopied | { count: number } |
rowExpanded | { id: string, expanded: boolean } |
rowPinnedChanged | { id: string, side: RowPinSide | null } |
rowMoved | { id: string, from: number, to: number } |
cellEdited | { rowId: string, columnId: string, oldValue: unknown, newValue: unknown } |
rowEdited | { rowId: string, changes: Record<string, unknown> } |
// The typed bus. on returns nothing; keep the handler if you need to stop.
grid.events.on('sortChanged', ({ sort }) => {});
grid.events.on('filterChanged', ({ filter }) => {});
grid.events.on('pageChanged', ({ page, pageSize }) => {});
grid.events.on('selectionChanged', ({ selectedIds }) => {});
grid.events.on('cellEdited', ({ rowId, columnId, oldValue, newValue }) => {});
grid.events.on('rowEdited', ({ rowId, changes }) => {});
grid.events.on('columnResized', ({ columnId, width }) => {});
grid.events.on('columnMoved', ({ columnId, toIndex }) => {});
grid.events.on('columnPinned', ({ columnId, side }) => {});
grid.events.on('columnVisibilityChanged', ({ columnId, hidden }) => {});
grid.events.on('rowsCopied', ({ count }) => {});
grid.events.on('rowExpanded', ({ id, expanded }) => {});
grid.events.on('rowPinnedChanged', ({ id, side }) => {});
grid.events.on('rowMoved', ({ id, from, to }) => {});GridState
What createDataGrid returns. data, density and locale are writable; the rest is read-only.
| Member | Description |
|---|---|
data | The rows. $state.raw, so assign a new array rather than mutating |
density | Row height and padding, writable |
locale | The BCP-47 tag in force, writable |
columns | Definitions, visible columns, widths, order and overrides, plus the header groups: groupDef, isCollapsed, isRail, foldableGroupOf, setGroupCollapsed and toggleGroup |
focus | The active cell and the keybindings features contributed |
expansion | Which rows are expanded, plus the enabled flag the viewport reads to render role="treegrid" rather than role="grid" |
announcer | The live region the grid speaks through |
events | The typed bus |
labels | Every string the chrome renders, merged from the locale pack |
rowModel | Where filtering, sorting and windowing happen |
api | getState and setState, plus what each feature contributed |
nodes / preWindowNodes / sourceNodes | Three points along the pipeline |
totalRows | preWindowNodes.length, which is what a footer counts |
nodeById(id) | Resolved against the unfiltered source |
getValue(node, column, purpose?) | The cell value, through accessor or row[id], and through any cellValue gate standing in front of that column. The default purpose is 'render', which is what the cell draws |
readerFor(id, purpose?) | The gate itself, so a pass over many rows asks once and then loops rather than asking per cell |
feature(id) | Untyped lookup by feature id |
Grid Parts
Every part of the component layer, all optional except Root.
| Part | What it renders |
|---|---|
Grid.Root | Sets the context every other part reads. Takes grid, ui and persistState |
Grid.Viewport | The scroller carrying role="grid" and the keyboard handlers |
Grid.Header | The sticky header, including nested group rows |
Grid.Body | The rows, plus the loading, empty and error surfaces |
Grid.Toolbar | A row above the viewport; you place what goes in it |
Grid.QuickFilter | The search input, with its own debounce prop |
Grid.FilterChips | One removable chip per active column filter |
Grid.ColumnChooser | The visibility menu |
Grid.DensityToggle | The three-way density control |
Grid.ExportMenu | Copy and CSV export, selection or all rows |
Grid.ContextMenu | Wraps the viewport and carries what features contribute |
Grid.StatusBar | Row counts and the selection summary |
Grid.Pagination | The footer, with pageSizes |