dom-grid
v0.4.0
Published
Table geometry engine: synchronous layout, scrolling, pinned columns, resizing and virtualization over your own markup.
Maintainers
Readme
dom-grid
Table geometry engine: synchronous layout, scrolling, pinned columns, resizing and virtualization over markup you own.
Why
A table has two kinds of change, and they want opposite things.
Structure changes rarely: which columns exist, how many rows there are, what sits in a cell. Declarative rendering fits it perfectly.
Geometry changes every frame: offsets while scrolling, a column edge following the pointer, the window of rows that has to exist. Here a framework update lands a frame late, and the header visibly trails the body.
dom-grid takes the second half. It writes to the DOM directly and synchronously, within
the same event, so nothing ever drifts apart by a frame. The markup stays yours: the engine
never queries the DOM, it only positions nodes handed to it.
Install
npm i dom-gridCore (no framework)
import { createGrid } from 'dom-grid'
const grid = createGrid({
root, body, headerRow, verticalScrollbar, horizontalScrollbar,
columns: [
{ key: 'id', width: 80, pinned: 'left' },
{ key: 'name' }, // 'auto' shares the leftover space
{ key: 'state', width: 100, pinned: 'right' },
],
rowHeight: 28,
rowCount: 5_000,
onRangeChange: ({ start, end }) => renderRows(start, end),
})
grid.registerHeaderCell(element, 'name') // the view hands nodes over as it creates them
grid.registerRow(element, rowIndex)
grid.registerCell(element, rowIndex, 'name')An element holds exactly one place: registering a recycled node under a new index drops its previous record immediately, so a node that comes back to an index it held before is never positioned by a stale entry.
| Method | What it does |
|---|---|
| registerHeaderCell / registerRow / registerCell | hand a node over, or pass null to drop it |
| setColumns / setRowCount | structure changed |
| setRowHeightSource(source) | swap the height source, a number or a function of the index |
| setRowHeight(index, height) / clearRowHeight(index) | override one row after measuring it, or return it to the source height |
| setPinnedLayers(left?, right?) | the pinned strips came or went |
| startColumnResize(key, event) | begin a drag on a column edge |
| startRowDrag(index, event) | begin dragging a row, reported through onRowDrop |
| scrollToRow(index) | move the viewport |
| getColumnWidths() | current widths, for persisting them |
| getResizedWidths() / setResizedWidths() | only the hand-resized ones, to save and restore |
| resetColumnWidths(key?) | drop hand-resized widths |
| rowOffset(index) / rowHeight(index) | where a row sits and how tall it is |
| layout / range / contentWidth / contentHeight / scrollPosition | current geometry |
| destroy() | detach every listener |
Pure helpers are exported too, so the maths can be used on its own: computeLayout(columns,
availableWidth), computeRange(...) and RowMetrics for variable row offsets.
Scroll modes
scrollMode decides who moves the content.
'overlay'(the default): the caller supplies two thin scrollbar strips, and the engine moves the content itself with a transform on the body. Wheel and touch are handled on the root.'native': rows live inside a real scrolling container, so the browser draws its own scrollbars and handles the wheel. The engine then places rows at their absolute offsets instead of compensating for the scroll, and a scroll event rewrites only what really follows it: a header outside the scroller, strips the browser does not move, a row a drag holds, and pinned cells that have no strip of their own. Wheel handling stays with the browser, sowheelis ignored here.
In native mode the same element is both the vertical and the horizontal scroller, and it is
the element columns are measured in. The Vue adapter wires that up on its own: leave
hScrollRef unused and it stands in for both, and the viewport becomes the scroller rather
than the body.
Sorting and selection
The engine does not touch your data, so it tracks state rather than doing the work.
import { SelectionModel, SortState } from 'dom-grid'
const sort = new SortState()
sort.toggle('name') // asc -> desc -> unsorted on repeated clicks
sort.value // [{ key: 'name', direction: 'asc' }], click order is priority
sort.toggle('city', true) // shift-click: adds the column, keeps 'name' sorted
sort.directionOf('name') // 'asc' | 'desc' | null
sort.priorityOf('name') // 1 for the first sorted column, null when unsorted
const selection = new SelectionModel({ isDisabled: (id) => locked.has(id) })
selection.toggle(id)
selection.selectRange(id, visibleIds) // shift-click, ids in the order shown
selection.allSelected(visibleIds)toggle(key, additive) behaves differently on the two paths, and the difference is
deliberate:
- a plain toggle replaces the whole sort with this one column and cycles it through ascending, descending and unsorted;
- an additive toggle appends the column, ascending, when it was not sorted, and
otherwise only flips its direction. It never drops the column: doing so would make the
one way to leave a multi-column sort the same gesture as the one way to build it. Use
clear(), orset([]), to end it.
multiple in SortOptions only changes the default of additive, so with multiple: true
an ordinary click already adds a column instead of replacing the sort. Priority follows the
order columns were added, not the latest click.
Selection works with row ids rather than indices, so it survives sorting and filtering.
In Vue both come as useSort() and useSelection() with the same API, wrapped in refs, plus
a set() on each for replacing the whole state when it is owned from the outside:
const sort = useSort({ multiple: true })
sort.set(savedEntries) // SortEntry[], as stored in user settings
const selection = useSelection()
selection.set(props.modelValue) // RowId[], disabled rows droppedGrouped headers
A header may be a tree: groups covering columns, columns covering nothing. Widths belong to
the engine, tiers belong to whoever draws the header, and layoutHeader is where the two
meet.
import { columnOffsets, headerDepth, type HeaderNode, headerLeaves, layoutHeader } from 'dom-grid'
const tree: HeaderNode[] = [
{ key: 'id' },
{ children: [{ key: 'city' }, { key: 'street' }] },
{ key: 'state', hidden: true },
]
headerLeaves(tree) // the columns, in display order: id, city, street
headerDepth(tree) // 2 tiers
columnOffsets(widths, ['id', 'city', 'street']) // left edge of each column
const header = layoutHeader(tree, widths)widths is Record<ColumnKey, number>, the resolved widths the engine reports. The result
is { depth, cells, handles }:
depthis how many tiers the header needs;- each cell in
cellscarrieskey, the caller's ownnode,isLeaf,depth(the tier it starts on, 0 at the top),rowSpan(a leaf under a shallow branch stretches down to the bottom tier),left,widthandleafKeys, the columns it stands over; - each handle in
handlesis a grip on the right edge of one column:key,node,edge(the boundary itself) anddepth, the topmost tier the grip may start on. A boundary inside a group splits that group's columns and not the group itself, so a grip drawn full height would cut the group in two; it starts below every cell it would have cut.
Only leaves are positioned by the engine, since only they are columns: register them with
registerHeaderCell and they follow the scroll and a resize drag on their own. A group has
no column, so nothing knows its node, and the caller places it from the left and width
of its box, recomputing on onLayoutChange. Groups are hidden with hidden: true, and a
children array that ends up empty takes the group with it.
The third argument is the display order of every column, service ones the tree knows nothing about included, so a checkbox column in front of the tree still shifts the groups behind it:
layoutHeader(tree, widths, ['select', ...headerLeaves(tree).map((node) => node.key!)])Vue
<script setup lang="ts">
import { useGrid } from 'dom-grid/vue'
const {
rootRef, bodyRef, headerRef, vScrollRef, hScrollRef,
visibleRows, contentWidth, contentHeight,
columnWidths, columnZones,
registerHeaderCell, registerRow, registerCell, grid,
} = useGrid({
columns: () => props.columns,
rows: () => props.data,
rowHeight: () => 28,
})
</script>
<template>
<div ref="rootRef" class="grid">
<div ref="headerRef">
<div v-for="col in columns" :key="col.key" :ref="(el) => registerHeaderCell(el, col.key)">
{{ col.key }}
</div>
</div>
<div ref="bodyRef">
<div v-for="row in visibleRows" :key="row.poolId" :ref="(el) => registerRow(el, row.index)">
<div v-for="col in columns" :key="col.key" :ref="(el) => registerCell(el, row.index, col.key)">
{{ row.data[col.key] }}
</div>
</div>
</div>
<div ref="vScrollRef"><div :style="{ height: `${contentHeight}px` }" /></div>
<div ref="hScrollRef"><div :style="{ width: `${contentWidth}px` }" /></div>
</div>
</template>Vue decides which rows and columns exist; the engine places them. Nodes travel through ref callbacks, so a re-render never leaves stale positions behind.
Key rows by row.poolId, not by the row id. The pool id is the row's slot in a recycling
pool, so the same DOM nodes stay alive while the window moves and the framework only patches
their content. Keying by data id instead destroys and rebuilds every row on each scroll,
which is what makes fast scrolling flicker.
What useGrid returns
Besides the refs and the register functions:
columnWidths: the resolved width of every column, refreshed on each geometry change, a resize drag included. Anything the engine does not position itself, a header group for one, is sized from these.columnZones:{ left, flow, right }, the column keys of each zone in display order. Where the pinned zone starts and where the flow ends is a fact about the layout, and a caller drawing the seam between them, or filling its own strips, needs it.pinnedLeftWidth/pinnedRightWidth: the width of each pinned zone. The engine keeps its own strips sized, but a header built as a second set of strips has to be told.contentWidth/contentHeight: sizes for the scrollbar spacers, taken from the engine because with variable rows the height is not a multiplication.viewportHeight: the height of the scrolling area as the engine last measured it.rowsPerViewport: how many whole rows fit that area. A table that asks for its data by the screenful gets its page size from here. With row heights that vary it is counted from the base height, because which rows fit depends on which ones they are.grid: the instance itself, for everything the adapter does not wrap.
Pass virtual: () => false to turn the window off: visibleRows then gives every row there
is, and the engine positions them all. It places any node registered with it and needs no
window of its own to do that. Short tables and tables that print are the reason to.
Resizing from the header
<div
class="resize-grip"
@pointerdown="startColumnResize(col.key, $event)"
/>
<div class="header-cell" @click="onHeaderClick(col.key)">{{ col.key }}</div>function onHeaderClick(key: string) {
// Releasing the pointer over the header also delivers a click there, and a
// header that sorts on a click must not sort because a column was widened.
if (wasRecentResize()) return
sort.toggle(key)
}startColumnResize(key, event) starts the drag and remembers when it ended;
wasRecentResize() is true for a short while after the release. isActivationKey(event) is
Enter or space, for a header cell that has to sort from the keyboard as well.
Resize grips as a layer
Inside a header cell a grip is cut off by its own tier: under a group it covers only the
lower tier, and one boundary reads as two stumps. The ResizeHandles component draws the
grips of the flow columns as a single layer spanning the whole header, clipped to the flow
zone, and follows the scroll from the same event the engine moves the columns from.
<ResizeHandles
:scroller="scroller"
:handles="header.handles.map(({ key, edge, depth }) => ({ key, left: edge - 6, top: depth * 40 }))"
:height="header.depth * 40"
:pinned-left="pinnedLeftWidth"
:pinned-right="pinnedRightWidth"
:start-resize="startColumnResize"
handle-class="my-grip"
/>The clipping is the point: a column scrolled under a pinned one is still there, and its grip
left on top would take the press meant for the pinned column's own grip. Pinned columns are
therefore left out of the list; their grips live inside their header cells, where the engine
already carries them. width (6 by default) has to agree with the offset the caller
subtracted from the edge. Structure and aria are the component's; how a grip looks is the
caller's, through handleClass.
Variable row heights
A row may carry more than its cells: an expanded panel, a nested table, a comment thread. How tall that is cannot be declared, since the panel may grow on its own after it opens, so it is measured instead. Hand the extra element over and the adapter watches it and keeps the row as tall as it needs, base row height plus the panel:
<div v-for="row in visibleRows" :key="row.poolId" :ref="(el) => registerRow(el, row.index)">
<div v-for="col in columns" :key="col.key" :ref="(el) => registerCell(el, row.index, col.key)">
{{ row.data[col.key] }}
</div>
<div v-if="expanded.has(row.id)" :ref="(el) => observeRowExtra(el, row.index)">
<RowDetails :row="row.data" />
</div>
</div>Passing null, which is what the framework does when the panel goes away, drops the
measurement and the row returns to its declared height. A recycled node arriving under
another index releases the row it measured before.
Under the hood this is grid.setRowHeight(index, height) and grid.clearRowHeight(index),
both available without Vue: the override replaces the source height of one row, everything
below shifts, and contentHeight and the visible range are recomputed within the same task.
useRowExpand keeps that bookkeeping for you. Expansion belongs to the row key and not to
its place, since after a sort another row stands at that index; the measured heights are
dropped from the indexes no expanded row stands at any more, before the rows are redrawn.
const expand = useRowExpand({ rowIds: () => rowIds.value, grid, observeRowExtra })<span role="button" :aria-expanded="expand.isOpen(row.index)" @click="expand.toggle(row.index)" />
<div v-if="expand.isOpen(row.index)" :ref="(el) => expand.observe(el, row.index)">
<RowDetails :row="row.data" />
</div>openIndexes is the whole set, for anything that would rather read it at once.
Custom scrollbars
A native scroller draws both bars inside itself, spanning its full edges; they
cannot be moved, and with pinned columns or a sticky header they end up running
under content. The Scrollbars component (Vue adapter) draws the two bars as
elements of their own, wherever the host lays them out, while scrolling itself
stays native. Thumbs are positioned from the same scroll event the engine moves
rows from, so bars and content land in their new place within one frame.
<div class="table" style="position: relative">
<div ref="scroller" style="overflow: auto; scrollbar-width: none">...</div>
<Scrollbars :scroller="scroller" :inset="{ top: 40 }" />
</div>The host hides the scroller's native bars and positions the custom ones via
inset (distances from the positioned ancestor's edges). thickness sets the
bar width, minThumb the shortest a thumb may get, and colors come from CSS
variables --dom-grid-track and --dom-grid-thumb, both with neutral defaults.
Bars hide themselves when their axis does not overflow. Dragging the thumb,
pressing the track and wheeling over a bar all work as the browser's own bars do.
Row reordering
createGrid({
...,
onRowDrop: (from, to) => moveRow(from, to), // reordering the data is yours
})
grid.startRowDrag(index, pointerEvent) // from a drag handleWhile a row is dragged the engine moves it under the pointer, opens a gap at the drop
position and scrolls when the pointer nears an edge. The dragged row carries a
data-dragging attribute so it can be styled. Drop indices are reported the way an array
behaves after a splice, and reorderRows(rows, from, to) does exactly that, giving the same
array back when nothing moved so the caller can tell there is nothing to report.
Input
Wheel and touch scrolling are handled on the root out of the box, including shift-wheel for
horizontal movement; the event is only swallowed when the table actually moved, so a table
scrolled to its end still lets the page scroll. Pass wheel: false to opt out. In
scrollMode: 'native' the browser does this itself and the engine keeps out of it.
Columns are laid out in the width of viewport (the Vue adapter defaults it to the body,
layoutFrom: 'root' switches back). Measure the element the content actually gets: overlay
scrollbars make the root wider than the space columns can use.
Markup contract
The engine positions nodes but does not style them. Your CSS has to provide:
rootwithposition: relativeandoverflow: hidden, it defines the available width;- header cells and body cells with
position: absolute, the engine setstransformandwidth; - rows with
position: absolute, the engine setstransformandheight; - scrollbars as separate scrollable elements with an inner spacer sized from
contentWidth/contentHeight.
Pinned columns
By default pinned columns cancel out the container shift in the scroll handler. With a native scroller the browser paints the shift itself, so a fast horizontal fling can show them a frame behind the flow.
Give them strips of their own and that disappears: the engine then places pinned cells at their offset inside the strip and never touches them again, and holding the strip in place is the page's job. A strip can be placed either way:
- inside the scroller, held by
position: sticky, so the browser keeps it in step with the scroll it is painting, on both axes; - outside the scroller, an absolutely positioned layer over the table, so nothing moves it horizontally at all, and the engine offsets its rows by the vertical scroll, the same way it does in overlay mode. Which one a strip is, the engine works out on its own from where the element sits.
createGrid({ root, body, pinnedLeftLayer, pinnedRightLayer, /* ... */ })Each strip holds its own copy of every visible row, so a row index has up to three elements, one per strip, and each is registered for what it is:
grid.registerRow(element, index) // the flow
grid.registerRow(element, index, 'left') // the left strip
grid.registerRow(element, index, 'right') // the right stripCells need no such hint: a column is pinned or it is not, and registerCell already knows
which. The engine keeps every strip sized to the columns it holds, including through a resize
drag. In Vue the strips are pinnedLeftRef and pinnedRightRef, and the adapter tells the
engine when they come and go; without Vue that is grid.setPinnedLayers(left, right). It
matters, because a strip can disappear with the last pinned column hidden, and the scrolling
area is a different thing then.
Strips also change what the scrolling area is. Without them it spans the whole table: the flow
reserves room for the pinned zones and slides underneath them, so contentWidth counts all
three. With them the area belongs to the flow alone, contentWidth is flowWidth, columns
start at the very beginning of it, and a scrollbar the browser draws for that area stays
between the pinned columns instead of running under them. Place the scrolling element between
the strips and give every pinned column an explicit width: there is no leftover space for a
pinned auto column to claim a share of, so it falls back to minColumnWidth.
A header placed inside the scroller is left alone as well: the browser carries it with the rest of the content, and nudging it would shift it twice over.
Working with heavy cells
Virtualization removes rows from the DOM, which destroys whatever lived inside them. When
cells are expensive (a select with thousands of options, an editor), pair this with
dom-attic: dom-grid owns where a cell sits,
dom-attic keeps what is inside it alive.
Development
npm run dev # playground
npm test # unit tests, run in a real browser
npm run test:e2e # scroll, pinning and resize scenarios
npm run buildLicense
MIT
