Table Advanced
Data Grid
Data Grid is the fourth capability level of Svelte Lean Table, not a second product: the same DataTable with cell focus, arrow-key navigation, editing, ranges and clipboard switched on by props. This page states which of those behaviors exist today and which do not.
- Fixture
- Benchmark
On this page
The capability levels are Table (semantic rendering), DataTable (sorting, filtering, selection,
pagination), Virtual Table (large row counts, not shipped) and Data Grid (cell focus, arrow-key
navigation, editing, pinning, range selection, copy and paste, grouping and tree). Nothing in
the grid level is a separate runtime: the renderer already contains the keyboard model and
enables it when onEdit or onCellRangeChange is set. Without them,
cells carry no tabindex and the table is a table.
The output stays a native <table>. There is no role="grid" emulation over divs: header cells keep scope, sorted columns keep aria-sort, and assistive technology reads the table as a table. The grid keyboard
pattern of the ARIA Authoring Practices is applied to the body: one Tab stop, arrow keys inside.
<!-- A body cell in grid mode, as rendered: focusable by structure, not by role="grid". -->
<td data-cell data-column="price" tabindex="-1" style="text-align:end;">
<div class="slean-table-cell">
12.00
<button type="button" class="slean-table-edit" tabindex="-1" aria-label="Edit: Price">…</button>
</div>
</td>What exists today
| Feature | Status | How | Specimen |
|---|---|---|---|
| Cell focus (roving tabindex) | implemented | Switched on by onEdit or onCellRangeChange; one cell carries tabindex 0, the rest -1; the body is one Tab stop. | Editing |
| Arrow-key navigation | implemented | Row-based: Up and Down find the cell with the same data-column in the neighbouring row, so spans and detail rows do not shift the target. Mirrored under dir=rtl. | Columns |
| Inline editing | implemented | Enter or F2 on a cell; native text, number, date and select editors; parse, validate, onEdit. | Editing |
| Range selection | implemented | Click, Shift+click and Shift+arrow through cellRange and onCellRangeChange; highlighted with data-range. | Clipboard & Export |
| Copy and paste | implemented | copyRange() and pasteRange() in the clipboard entry; the key binding is the application’s. | Clipboard & Export |
| Pinned columns | implemented | pin on a column or state.pinning; sticky cells with logical insets. | Columns |
| Grouping and tree rows | implemented | groupBy() row model; getSubRows for hierarchy; expansion in the state. | Grouping & Pivot |
| Sticky header and scrolling surface | implemented | height limits the region; the header stays in view; scrollTo() reaches a row. | Layout |
| Virtual rows | not implemented | Every row stays in the document. A windowed mode is a future opt-in (@svelte-lean/virtual), extracted only when a reusable need is proved. | – |
| Home, End, Page Up, Page Down in the body | not implemented | Only the four arrow keys, Enter, F2 and Escape are handled in cells. | – |
| Keyboard navigation in the header row | not implemented | Header sort buttons and resize handles are ordinary Tab stops. | – |
| Context menus and cell tooltips | not implemented | Cell content that needs an overlay uses a primitive or an application singleton; the menu contract has no context-menu mode. | – |
Keyboard
With cell focus enabled. Outside grid mode the body has no keyboard model of its own.
| Key | When | Result |
|---|---|---|
| Tab | before the body | Focuses the last cell reached, or the first rendered data cell. |
| Tab | on a cell | Leaves the body to the next control after it (the pager); cells are one Tab stop. |
| ArrowLeft/ArrowRight | on a cell | Previous or next data cell in the row; mirrored under dir=rtl. |
| ArrowUp/ArrowDown | on a cell | The cell with the same column in the previous or next data row; detail rows and group rows are skipped. |
| Shift+Arrow | with onCellRangeChange | Moves and extends the range. |
| Enter/F2 | on an editable cell | Opens the editor. |
| Enter | in the editor | Saves; focus returns to the cell. |
| Escape | in the editor | Cancels; focus returns to the cell. |
Composition
A grid is a composition of props and entry points. The example enables editing and ranges, pins the identity column, limits the height, and binds copy to the keyboard in the application, because which key copies is a product decision, not the table's.
<script lang="ts">
import { DataTable, type CellRange, type EditEvent } from '@svelte-lean/table';
import { applyEdit } from '@svelte-lean/table/editing';
import { copyRange } from '@svelte-lean/table/clipboard';
import '@svelte-lean/table/style.css';
let rows = $state(load());
let range = $state<CellRange | null>(null);
// Editable columns, a pinned identity column, a wide scrolling middle.
const columns = [
{ id: 'sku', header: 'SKU', pin: 'left' as const, width: 120 },
{ id: 'name', header: 'Product', editable: true, width: 240 },
{ id: 'price', header: 'Price', align: 'end' as const, editable: true, editor: 'number' as const },
{ id: 'stock', header: 'Stock', align: 'end' as const, editable: true, editor: 'number' as const }
];
</script>
<!-- onEdit and onCellRangeChange switch the body to the grid pattern: one Tab stop, arrow keys, Enter/F2. -->
<DataTable
data={rows}
{columns}
getRowId={(row) => row.sku}
onEdit={(event: EditEvent<Product>) => (rows = applyEdit(rows, event, columns, (row) => row.sku))}
cellRange={range}
onCellRangeChange={(next) => (range = next)}
stickyHeader
height="32rem"
pagination={false}
label="Inventory"
/>
<svelte:document
onkeydown={(event) => {
if (range && event.key === 'c' && (event.metaKey || event.ctrlKey)) {
navigator.clipboard.writeText(copyRange(range, rows, columns, (row) => row.sku));
}
}}
/>Scale
Rows are not virtualized, so the cost of a large grid is the cost of its DOM. The numbers below are the medians the playground benchmark recorded for the whole table in the document, with the environment of that run; they are read from the benchmark file and say nothing about other hardware or other libraries. A missing file renders "not measured".
| Subject | Median | p95 | Method |
|---|---|---|---|
| Mount, one thousand rows | 283.8 ms | 342.8 ms | component init to the second animation frame after onMount, client render of the whole table (ssr=false); median of 5 loads |
| Sort by revenue, one thousand rows | 114.1 ms | 117.7 ms | header button click (ascending by revenue) to the second animation frame; median of 5 |
| Mount, ten thousand rows | 3907.1 ms | 5984.2 ms | component init to the second animation frame after onMount, client render of the whole table (ssr=false); median of 5 loads |
| Sort by revenue, ten thousand rows | 1928.4 ms | 2142.4 ms | header button click (ascending by revenue) to the second animation frame; median of 5 |
Environment: chromium 153.0.8010.53, Apple M2 (8 cores, 16 GiB), darwin
27.0.0, Node v22.23.3, production build, headless yes, run
2026-09-27. Reproduce with yarn workspace @svelte-lean/playground bench.
API
| Name | Of | Type | Default | Description |
|---|---|---|---|---|
onEdit | Events and composition | (event: EditEvent<T>) => void | Promise<void> | undefined | Receives a parsed and validated cell edit; enables editing and cell focus. The table never mutates rows. |
cellRange | Events and composition | CellRange | null | undefined | The highlighted rectangular range; with onCellRangeChange cells become focusable. |
onCellRangeChange | Events and composition | (range: CellRange | null) => void | undefined | Reports a click, Shift+click or Shift+arrow range. |
height | Presentation | string | undefined | max-height of the scroll region; with it the region is a Tab stop. |
stickyHeader | Presentation | boolean | true | Keeps the <thead> in view. With height it sticks inside the scroll region; without, it sticks to the page while the table fits its width (offset by --slean-table-sticky-top). A table wider than its container scrolls sideways in the region, which captures sticky positioning: the header then scrolls with the rows unless the region also scrolls down. |
pin | Layout | 'left' | 'right' | false | false | Initial sticky edge; state.pinning overrides it. |
editable | Editing | boolean | ((row: T) => boolean) | false | Cell editing for the column or per row; needs onEdit on the table. |
CellPosition | Events and positions | { row: RowId; column: string } | – | A cell by row id and column id. |
CellRange | Events and positions | { start: CellPosition; end: CellPosition } | – | An inclusive rectangle anchored by two positions. |
@svelte-lean/table/clipboard | Entry points | copyRange · pasteRange · rangeBounds | – | A rectangular range to tab-separated text and back to validated EditEvents. |
@svelte-lean/table/editing | Entry points | applyEdit · setValue · parseEdit · createHistory · editEvent | – | Immutable edit application and a caller-owned undo history. |