DataTable
A data grid: sort, search, filter, resize, reorder, pin, group, select, edit, page or virtualize, save views and export.
Payments
A payments table: select rows, sort from the header menu, filter by status, search, hide columns, page through, open a row's actions.
Code
const COLUMNS: DataTableColumn[] = [
{ key: "status", type: "tag", options: STATUSES, facet: "select" },
{ key: "email", header: "Email" },
{ key: "amount", type: "currency" },
];
const ACTIONS: RowAction[] = [
{ id: "copy", label: "Copy payment ID", icon: "copy", onSelect: (row) => copy(row.id) },
{ id: "customer", label: "View customer", separator: true, onSelect: (row) => go(row) },
{ id: "details", label: "View payment details", onSelect: (row) => open(row) },
];
<DataTable data={payments} columns={COLUMNS} label="Payments"
selection="multiple" selected={state.selected} actions={ACTIONS}
groupedColumnMode="reorder" />Usage
import { DataTable, DataTableColumn, RowAction } from "@elements/ui/data-table";
const COLUMNS: DataTableColumn[] = [
{ key: "status", type: "tag", options: STATUSES, facet: "select" },
{ key: "email", header: "Email", rowHeader: true },
{ key: "amount", type: "currency", summary: "sum", editable: true },
];
<DataTable data={payments} columns={COLUMNS} label="Payments"
selection="multiple" selected={state.selected}
actions={ACTIONS} onSave={(changes) => persist(changes)} />
<!-- a LiveView stays live: rows patch in place, edits call payments.update() -->
<DataTable data={payments} columns={COLUMNS} editMode="direct" />Examples
Every feature
Faceted filters, the filter builder with nested filter groups (Add group), multi-sort (Shift), resize, drag to reorder, pin, hide, group, density, saved views, CSV export, a summary row and a sticky header.
Code
<DataTable data={people} columns={COLUMNS} label="People" height="28rem"
selection="multiple" pageSize={20} pageJump
facets={state.facets} filters={state.filters} pinned={state.pinned}
sizes={state.sizes} hidden={state.hidden} order={state.order}
views={state.views} view={state.view} density={state.density}
onStateChange={(s) => save(s)} />
<!-- Filter groups: a rule with rules stands as one rule, joined inside by its own join. -->
filters = [
{ id: "r1", column: "salary", op: "gt", value: "150000" },
{ id: "g1", column: "", op: "contains", value: "", join: "or", rules: [
{ id: "r2", column: "level", op: "equals", value: "5" },
{ id: "r3", column: "level", op: "equals", value: "6" },
] },
];Grouped rows and column groups
Rows grouped by team, with aggregates (mean level, total salary) on the group rows; Work and Pay are column groups over their columns. The search is fuzzy: the letters in order. columnResizeMode=onEnd: a resize handle previews and the column resizes on release.
Code
<!-- group by team; numbers aggregate (sum, mean, min), headers group columns -->
<DataTable data={people} columns={[
{ key: "name", rowHeader: true },
{ header: "Work", id: "work", columns: [{ key: "team" }, { key: "role" }] },
{ header: "Pay", id: "pay", columns: [{ key: "level", type: "number" }, { key: "salary", type: "currency" }] },
]} grouping={state.grouping} columnResizeMode="onEnd" />Grouped column removed
groupedColumnMode="remove": the column a table is grouped by leaves the grid, and each group row shows its value in the first column.
Code
<DataTable data={payments} columns={COLUMNS} grouping={["status"]} groupedColumnMode="remove" />Inline editing with a changeset
Enter, F2 or typing edits a cell; Enter commits, Escape restores. Edits wait in changes (two-way) until Save; invalid values are flagged. Add row inserts one.
Code
const COLUMNS: DataTableColumn[] = [
{ key: "title", editable: true, validate: (v) => v ? "" : "A title is required" },
{ key: "priority", type: "tag", options: PRIORITIES, editable: true },
{ key: "estimate", type: "number", editable: true, summary: "sum" },
{ key: "due", type: "date", editable: true },
{ key: "done", type: "boolean", editable: true },
];
<!-- edits and added rows wait until Save; onSave gets them all -->
<DataTable data={state.tasks} columns={COLUMNS} changes={state.changes}
addable newRow={() => ({ id: crypto.randomUUID(), title: "", estimate: 0, done: false })}
onSave={(changes, newRows) => persist(changes, newRows)} />Direct editing
editMode="direct": every commit writes the row at once, the way a LiveView update reaches every browser.
Code
<!-- editMode="direct": each edit writes the row at once (a LiveView updates for everyone) -->
<DataTable data={todos} columns={COLUMNS} editMode="direct" onCellEdit={(c) => log(c)} />Tree rows
subRows makes a row expandable; children indent under it. With selection, checking a parent checks its sub-rows, and a parent with only some of them checked shows mixed.
Code
<DataTable data={FILES} columns={COLUMNS} subRows={(row) => row.children} expanded={state.open}
selection="multiple" pagination={false} />Row details
detail adds an expand button; the open row (two-way detailRow) shows the detail slot under it.
Code
<DataTable data={payments} columns={COLUMNS} detail detailRow={state.open}>
<div slot="detail">Payment {state.open?.id} to {state.open?.email}</div>
</DataTable>Pinned rows
rowPinning adds Pin to top and Pin to bottom to each row's menu; pinned rows stay in view while the rest scroll.
Code
<DataTable data={payments} columns={COLUMNS} rowPinning pinnedRows={state.pinnedRows} actions={ACTIONS} />Ten thousand rows
virtual renders only the rows in view; sorting and search run over all of them.
Code
<!-- 10,000 rows, only the visible ones rendered -->
<DataTable data={rows} columns={COLUMNS} virtual pagination={false} height="24rem" rowHeight={40} />Live rows
Rows that change in place (a LiveView patching a row another browser wrote) re-sort and re-total by themselves. An edit goes through the view's update(). columnMenu=false: a header click sorts.
Code
// route: return new html({ payments: payments.view() });
<DataTable data={payments} columns={COLUMNS} editMode="direct" columnMenu={false}
sorting={[{ id: "amount", desc: true }]} />
<!-- a LiveView patches rows in place; the table re-sorts and re-totals itself,
and an edit calls payments.update(row) for every watching browser -->Server-side data
manual hands sorting and paging to the server: the table shows the page it is given, rowCount sizes the pager, loading veils the rows.
Code
<!-- manual: the server sorts and pages; the table shows what it is given -->
<DataTable data={state.rows} columns={COLUMNS} manual rowCount={state.total}
page={state.page} pageSize={state.pageSize} sorting={state.sorting}
loading={state.loading} onStateChange={() => fetchPage(state)} />Single selection
selection=single with selectOnClick: a click selects the row; double-click or Enter runs onRowAction. canSelect keeps failed payments out; rowTone tints them.
Code
<DataTable data={payments} columns={COLUMNS} selection="single" selectOnClick
selected={state.selected} canSelect={(row) => row.status !== "failed"}
rowTone={(row) => row.status === "failed" ? "danger" : ""}
onRowClick={(row) => preview(row)} onRowAction={(row) => open(row)} />Controlled state
sorting, search and page are two-way: set them from outside and the table follows.
These drive the Payments table at the top of the page.
Code
<button onclick={() => state.sorting = [{ id: "amount", desc: true }]}>Largest first</button>
<button onclick={() => state.search = "gmail"}>Search gmail</button>
<button onclick={() => state.page = 2}>Page 3</button>
<DataTable data={payments} columns={COLUMNS} sorting={state.sorting} search={state.search} page={state.page} />Empty and loading
No rows shows emptyText (or the empty slot); loading veils the rows with a spinner and keeps every size.
Code
<DataTable data={[]} columns={COLUMNS} emptyText="No payments yet." />
<DataTable data={payments} columns={COLUMNS} loading={state.loading} />Column formats
Column formats: tags as badges, a time of day, a phone link, a star rating, JSON, an image, a BigInt and multiline text. Hours is editable within 0 to 40 (a value outside is corrected), its faceted filter counts rows by bucket, and its background marks long and short tickets (a conditional column background). Summary is Markdown: bold, italic, code, strikethrough and links, with any other markup shown as text.
docs/start page$40 approveddocs/start page$40 approveddocs/start pageCode
<DataTable data={tickets} columns={[
{ key: "labels", type: "tags", options: LABELS, facet: "select" },
{ key: "opened", type: "time" },
{ key: "phone", type: "phone" },
{ key: "rating", type: "rating" },
{ key: "hours", type: "number", editable: true, min: 0, max: 40, facet: "select",
bucket: (v) => (v < 10 ? "Under 10" : v < 25 ? "10 to 25" : "25 and over"),
background: (v) => (v >= 25 ? "danger" : v < 10 ? "success" : "") },
{ key: "summary", type: "markdown" }, // **bold**, _italic_, `code`, ~~strike~~, [links](https://...)
{ key: "meta", type: "json" },
{ key: "avatar", type: "image" },
{ key: "bytes", type: "bigint" },
{ key: "notes", type: "multiline" },
]} />Sorting options
invertSorting puts first place on top of a descending sort; sortUndefined: first; sortDescFirst on Name; maxMultiSort=2 with Shift+click; sortRemoval=false cycles between ascending and descending. Code sorts with textCaseSensitive (capitals first) and Squad with alphanumericCaseSensitive (numbers in order, capitals first).
Code
<DataTable data={runners} columns={[
{ key: "name", sortDescFirst: true },
{ key: "place", type: "number", invertSorting: true },
{ key: "time", type: "number", sortUndefined: "first" },
{ key: "code", sortFn: "textCaseSensitive" }, // Alpha, Beta, alpha, beta
{ key: "squad", sortFn: "alphanumericCaseSensitive" }, // Pace 2, Pace 10, pace 1
]} columnMenu={false} maxMultiSort={2} sortRemoval={false} />Reorder rows
rowReorder adds a drag handle: drag a row, or focus its handle and press Space, the arrows, then Space. An array is reordered in place.
Code
<DataTable data={rows} columns={COLUMNS} rowReorder onRowReorder={(id, to) => save(id, to)} />Cursor paging
manual with rowCount -1: the total is unknown, so the pager shows the page alone, hasNextPage enables Next, and Last stays off (cursor-based server pagination).
Code
<DataTable data={state.rows} columns={COLUMNS} manual rowCount={-1} hasNextPage={state.hasNext}
page={state.page} onPageChange={(p) => fetchPage(p)} />Cell selection
cellSelection: a click selects a cell; Shift+arrows, Shift+click or a drag select a range, Ctrl or Cmd+click adds a range or takes a selected cell out, Ctrl+A selects every cell, Ctrl+C copies them as tab separated text, Escape clears. cellRanges is two-way, and onTable reaches the engine's cell selection APIs.
Payment sheet
Shift+arrows or a drag select cells; Ctrl+Space selects a column; Ctrl+C copies.
Code
<h3 id="sheet-title">Payment sheet</h3>
<DataTable data={payments} columns={COLUMNS} cellSelection labelledBy="sheet-title"
cellRanges={state.ranges} onCellSelectionChange={(c, ranges) => state.cells = c}
onTable={(api) => api.table.selectAllCells()} />Spreadsheet
The spreadsheet example: cellSelection with editable columns. Type over a cell, or press Enter, F2 or double click to edit it; Enter commits and moves down, Tab moves right. Ctrl+V pastes tab separated text from the selection (one value fills it), Delete and Ctrl+X clear it, Ctrl+Z and Ctrl+Shift+Z undo and redo, and a right click opens Cut, Copy, Paste, Clear contents, Merge cells and Sort. The value bar shows the active cell's name and value and writes it back; drag the fill handle on the selection's corner to repeat values or continue a series (Ctrl+D and Ctrl+R fill down and right); Ctrl+M merges the selected cells; zoom scales the grid; each sheet keeps its own rows, merges and undo.
Code
<!-- One table per sheet (the key remounts it, so each sheet keeps its own
selection and undo). merges is two-way; zoom scales the grid. -->
<DataTable e:for={sheet of [activeSheet]} e:key={(s) => s.id}
data={sheet.rows} merges={sheet.merges} mergeable valueBar zoom={zoom}
cellSelection editMode="direct" columns={[
{ key: "item", header: "Item", editable: true },
{ key: "owner", header: "Owner", editable: true },
{ key: "category", header: "Category", editable: true },
{ key: "cost", header: "Cost", type: "currency", editable: true, summary: "sum" },
]} onTable={(t) => api = t} />
<!-- The selection's corner has a fill handle: drag it to repeat values or
continue a series (100, 200 then 300). Ctrl+D and Ctrl+R fill down and right.
Ctrl+M or the cell menu merges the selected cells, or unmerges them. -->
<button onclick={() => api.toggleMerge()}>Merge cells</button>
<button onclick={() => api.undo()}>Undo</button>Cell selection options
cellSelectionDrag=false: a drag selects the cell it started on. cellRangeSelection=false: Shift+click starts over. Status opts out (cellSelection: false on the column). autoResetCellSelection: new data clears the selection.
Code
<DataTable data={rows} columns={[{ key: "status", cellSelection: false }, ...]} cellSelection
cellSelectionDrag={false} cellRangeSelection={false} autoResetCellSelection />Cell spanning
Region merges equal adjacent values (spanRows: true), Month merges by a predicate (the same month), each total row's label runs to the end of the row (spanColumns: Infinity), Status opts out. A merged cell is one keyboard stop and is selected whole.
Code
<DataTable data={sales} cellSelection columns={[
{ key: "region", spanRows: true },
{ key: "month", spanRows: (row, anchor, value, anchorValue) => sameMonth(value, anchorValue) },
{ key: "product", spanColumns: (row) => (row.isSummary ? Infinity : 1) },
{ key: "units", type: "number" },
{ key: "status", spanning: false },
]} />Table methods and events
The table's methods through onTable, and its events: Change Page, Change Filter, Click Cell, Select Cell, Blur, Expand Row, Discard.
Code
<DataTable data={payments} columns={COLUMNS} selection="single" onTable={(api) => table = api}
onPageChange={...} onFiltersChange={...} onCellClick={...} onCellFocus={...} onBlur={...} />
table.selectNextRow(); table.setSort([{ id: "amount", desc: true }]); table.exportData({ format: "json" });Summaries
summary puts an aggregate of the filtered rows in the footer: sum, min, max, mean, median, count, uniqueCount, extent, first, last and unique, a summary row.
Code
<DataTable data={runners} columns={[
{ key: "place", summary: "sum" }, // or min, max, mean, median, count,
{ key: "place", summary: "extent" }, // uniqueCount, unique, extent, first, last
]} />Tree filtering
filterFromLeafRows keeps a folder when a file in it matches the search; paginateExpanded=false keeps a folder's open rows on its page.
Code
<DataTable data={FILES} columns={FILE_COLUMNS} subRows={(r) => r.children}
filterFromLeafRows paginateExpanded={false} pageSize={2} />Modifier keys
isMultiSortEvent and isRowRangeSelectionEvent pick the modifier: here Alt+click on a header adds it to the sort and Alt+click on a checkbox selects the range from the last one. Below, isCellRangeSelectionEvent makes Alt+click extend a cell range and isMultiCellRangeSelectionEvent makes Shift+click add one.
Code
<!-- Alt+click a header adds it to the sort; Alt+click a checkbox selects the range -->
<DataTable data={payments} columns={COLUMNS} selection="multiple"
isMultiSortEvent={(e) => e.altKey}
isRowRangeSelectionEvent={(e) => e.altKey} />
<!-- Alt+click extends a cell range; Shift+click adds one -->
<DataTable data={payments} columns={COLUMNS} cellSelection
isCellRangeSelectionEvent={(e) => e.altKey}
isMultiCellRangeSelectionEvent={(e) => e.shiftKey} />Resetting on new data
autoResetPageIndex, autoResetSorting and autoResetExpanded (or autoResetAll for each): new data returns to page one, clears the sort and collapses groups. All off by default, since a LiveView hands the table new rows on every change. Data that shrinks under the page always shows its last page.
Code
<!-- New data (a new array) returns to page one, clears the sort and collapses groups -->
<DataTable data={state.rows} columns={COLUMNS} autoResetAll />
<!-- or one at a time -->
<DataTable data={state.rows} columns={COLUMNS} autoResetPageIndex autoResetSorting autoResetExpanded />Aggregation options
Level shows two aggregates on each group (min and max) and three in the summary; Salary uses a custom aggregation (Payroll); Status aggregates a value of its own (aggregationValue: 1 for active). maxAggregationDepth limits aggregates to the outer groups, and getAggregationValue takes them from the caller (server totals).
Code
const COLUMNS: DataTableColumn[] = [
{ key: "level", type: "number", aggregate: ["min", "max"], summary: ["min", "max", "mean"] },
{ key: "salary", type: "currency", aggregate: { label: "Payroll", fn: (values) => sum(values) } },
{ key: "status", aggregationValue: (row) => (row.status === "active" ? 1 : 0),
aggregate: [{ label: "Active", fn: (values) => sum(values) }, "count"] },
];
<DataTable data={people} columns={COLUMNS} grouping={["team", "role"]} maxAggregationDepth={1}
getAggregationValue={(group, column) => serverTotals[group.value]?.[column]} />Grouped and expanded on the server
manualGrouping: the server sends team rows with their people as sub-rows, and the table keeps them as they are instead of grouping again. manualExpanding: expanded is state only, and the caller places an open folder's files in the data. onTable's pinRow pins a team with its people (includeLeafRows).
Code
<!-- The server sends team rows with their people as subRows: the table does not group again -->
<DataTable data={teams} columns={COLUMNS} subRows={(row) => row.people}
grouping={["team"]} manualGrouping />
<!-- manualExpanding: the caller places an open folder's files in the data -->
<DataTable data={visibleFiles(state.expanded)} columns={COLUMNS} subRows={(row) => row.children}
expanded={state.expanded} manualExpanding />Generated columns
columnsFromData builds the columns from every row, and defaultColumn gives each one its size. Note is HTML, cut down to safe inline formatting; Priority sorts by the label it shows (sortMode display). density dense gives extra small rows; alwaysShowScrollbars keeps both scrollbars drawn.
Code
<!-- Columns generated from the data; every column starts 160px wide -->
<DataTable data={rows} columns={columnsFromData(rows)} defaultColumn={{ size: 160, minSize: 96 }}
density="dense" alwaysShowScrollbars height="14rem" />
{ key: "note", type: "html" } <!-- inline HTML, cut down to safe formatting -->
{ key: "priority", sortMode: "display" } <!-- sorts by the text shown, not the raw value -->Row models in a web worker
Ten thousand rows sorted, searched and paged off the main thread: the table is manual, and a web worker answers each change of page, sort or search with one page of rows.
Code
<!-- The row models run in a web worker: the table is manual, the worker sorts, searches and pages -->
<DataTable data={state.rows} columns={COLUMNS} manual rowCount={state.total}
page={state.page} pageSize={state.pageSize} sorting={state.sorting} search={state.search}
loading={state.loading} onStateChange={() => worker.postMessage(query(state))} />