Pulliv0.1
8px
14 — Data grid

The blocker, built.

The readiness assessment named this as the single component whose absence rules out most fintech and healthcare applications. Ten thousand rows, roughly forty in the DOM at any moment, sortable, filterable, resizable, freezable and selectable — with the ARIA that virtualisation breaks if you do not supply it explicitly.

Live grid

Real data, generated on load. Drag a column edge to resize. Tab into it and use the arrow keys — the whole grid is one tab stop.

Working

  • Row virtualisationyes
  • Sort — tri-stateyes
  • Per-column filteryes
  • Global filteryes
  • Column resize — pointeryes
  • Frozen columnsyes
  • Bulk select + shift-rangeyes
  • Header tri-state checkboxyes
  • Column chooseryes
  • Keyboard grid navigationyes
  • Live-region announcementsyes
  • Forced colorsyes
  • Density tiersyes

Tabular figures and locale formatting. Numeric columns use font-variant-numeric: tabular-nums and Intl.NumberFormat, right-aligned so digits line up down the column. That closes part of the fintech number-system gap as a side effect — a grid is where it matters most.

Row height follows density. It resolves --alavu-md at runtime, so the virtualisation maths stays correct when you change the pitch in the header or switch density. Drag the pitch control and watch the row count in view change.

Selection survives filtering. Selected ids are held independently of the current view, so filtering down, selecting, and clearing the filter keeps the selection — which is what users expect and what naive implementations get wrong.

The grid is a lattice

This is the most literal lattice in the system — rows crossing columns, with the intersections where the line would turn. Nothing had to be invented to make it fit.

The sorted column's header dot is wrapped by a loop, which is the same primitive as tabs, the stepper and the toggle. Correctly so: choosing a sort column is choosing among options along a line.

Direction stays an arrow. The loop says which column; the arrow says which way. One mark should not carry both, and a loop cannot express direction without becoming a glyph.

Selection is a fill, not a loop. A loop on every selected row would be line work at frequency, which the density ceiling forbids. This is the distinction the system got wrong once and corrected in section 04.

Tokens consumed

  • Row height--alavu-md
  • Cell padding--space-inset-sm
  • Rules--border-hairline
  • Header ground--color-surface-sunken
  • Selected row--color-accent-subtle
  • Sort loop2.5 P, kambi-2
  • Sort pulli0.5 P
  • Focus ring--border-edge inset
  • Loop reveal--motion-reveal

One literal in the whole component: the select column is 44px, because it is a target and targets are floored rather than derived.

Virtualisation

The body is a spacer of rowCount × rowHeight, and only the visible window plus overscan is rendered, each row absolutely positioned at index × rowHeight. Scroll handling is throttled to one requestAnimationFrame, and the window is only re-rendered when the start or end index actually changes — scrolling within a row costs nothing.

The cost is DOM nodes, not arithmetic. Ten thousand rows is the same work as one hundred; what changes is filter and sort time, which is linear and runs once per interaction rather than once per frame.

var rh    = rowHeight();              // resolves --alavu-md
var start = max(0, floor(top / rh) - overscan);
var end   = min(n, ceil((top + vh) / rh) + overscan);

body.style.height = n * rh + 'px';
row.style.top     = i * rh + 'px';

// only repaint when the window moves
if (start === _start && end === _end) return;

Keyboard

Roving tabindex over cells — the entire grid is one tab stop, so a keyboard user never has to tab through ten thousand rows to reach the content after it.

KeyAction
← → ↑ ↓Move one cell
Home / EndFirst / last cell in the row
Ctrl+Home / EndFirst / last cell in the grid
PageUp / PageDownOne viewport
SpaceToggle row selection — or sort, on a header cell
Shift+SpaceExtend selection from the anchor
EnterActivate the row — or sort, on a header cell
← → on a resize handleResize the column by one lattice step
Keyboard column resizing is the part most grids skip. Dragging a border is a pointer-only interaction, and a column that cannot be widened by keyboard is a column whose truncated content some users simply cannot read. The handle is a focusable separator with arrow-key handling — one of the few WCAG requirements that is easy to satisfy and almost universally missed.

ARIA contract

Virtualisation breaks screen readers unless you compensate. A reader derives row and column counts from the DOM, and in a virtualised grid the DOM contains forty rows out of ten thousand. Without explicit counts it announces "row 3 of 40" when the user is at row 4,812.

So the grid supplies them: aria-rowcount and aria-colcount on the grid, aria-rowindex on every row — 1-based with the header as row 1 — and aria-colindex on every cell.

A polite live region announces sort changes, filter result counts, selection counts and column resizes, because none of those produce a focus change that a reader would otherwise notice.

<div role="grid" aria-rowcount="10000" aria-colcount="8">
  <div role="row" aria-rowindex="1">
    <div role="columnheader" aria-colindex="3"
         aria-sort="descending">Notional</div>
  </div>
  <div role="row" aria-rowindex="4813"
       aria-selected="true">
    <div role="gridcell" aria-colindex="3">…</div>
  </div>
</div>

<div role="status" aria-live="polite">
  Notional sorted descending
</div>
Claimed, not yet proven. This follows the ARIA grid pattern correctly as written, but it has not been through a screen-reader test matrix — NVDA, JAWS and VoiceOver each have quirks with virtualised grids that only testing surfaces. That row in the gap analysis is still open, and this component does not close it.

API

<link rel="stylesheet" href="assets/grid.css">
<script src="assets/grid.js"></script>

const grid = new PulliGrid('#grid', {
  columns: [
    { key: 'id',       label: 'ID',       width: 96,  frozen: true },
    { key: 'book',     label: 'Book',     width: 150, frozen: true },
    { key: 'notional', label: 'Notional', width: 150, type: 'currency' },
    { key: 'status',   label: 'Status',   width: 130, type: 'status' }
  ],
  rows: data,                  // each row needs a stable `id`
  selectable: true,
  currency: 'USD',
  onActivate: row => open(row)
});

grid.getSelected();     // rows, not indices — survives filtering
grid.setRows(next);
grid.toggleSort('notional');
grid.clearSelection();
Column optionValuesNote
typetext | number | currency | statusnumber and currency get tabular figures, right alignment and Intl formatting
frozenbooleanSticky with a computed cumulative offset, so freezing several works and survives resizing
width / minWidthpxminWidth is the resize floor
sortable / filterablebooleanDefault true
formatfn(value)Overrides type formatting entirely
alignstart | end | centerDefaults by type

What it still does not do

This closes the blocker for read-heavy applications. It is not AG Grid, and the honest list matters more than the feature list.

Not implemented

  • Cell editing. No inline edit, no validation, no dirty state, no commit or revert. The largest missing piece by far.
  • Row grouping and aggregation. No group headers, subtotals or expand/collapse.
  • Column reorder by drag. The chooser hides and shows; it does not reorder.
  • Multi-column sort. One sort key at a time.
  • Column virtualisation. Rows are windowed; columns are not. Beyond roughly forty columns this will slow down.
  • Server-side data. Everything is in memory. Real fintech volumes need paged or streamed fetching with a loading placeholder per row.
  • Export. No CSV or Excel.
  • Filter types. Text contains only — no numeric ranges, date ranges, or set filters.
  • Row detail expansion, pinned rows, footer totals.

What this changes in the assessment

The gap analysis rated the data grid a blocker at three to four months to build properly. What exists now is a read-heavy grid: enough for reporting, reconciliation, admin consoles, search results and monitoring — which is a large share of enterprise screens — and not enough for anything where users edit in the grid.

Component count moves from 13 to 14 of ~58. The advice in the cheaper path stands: for editing, grouping and server-side data, adopt a headless grid and dress it in these tokens rather than continuing to build this one.