@agility-workbench/grid
v1.3.0
Published
A high-performance, framework-agnostic TypeScript data grid for building modern data workspaces.
Maintainers
Readme
@agility-workbench/grid
A high-performance, framework-agnostic TypeScript data grid for building modern data workspaces.
- Framework-agnostic core — the grid engine (
GridCore+GridRenderer) is plain TypeScript with no framework dependency. - React binding — using React? Install
@agility-workbench/react-grid, a thin<Grid />adapter built on this core. - Angular binding — using Angular 20.3+? Install
@agility-workbench/angular-grid, which provides the standalone<awb-grid>component on top of this core. - Virtualized rendering, client-side and server-side row models, row grouping, tree data,
aggregation, client-side pivot mode, spreadsheet-style sheet tabs, quick filter, editing, and
CSV / Excel export (zero-dependency
.xlsxwriter). - Themeable via an AG-Grid-style theme object that resolves to CSS variables applied per grid instance.
Installation
npm install @agility-workbench/gridFor React apps, install the binding instead (it depends on this package):
npm install @agility-workbench/react-grid react react-domFor Angular 20.3+ apps, install the Angular binding instead (it also depends on this package):
npm install @agility-workbench/angular-gridQuick start (core, no framework)
createGrid mounts a grid into an element and hands back its API. It owns the whole
assembly — text measurer, core, renderer, menus, and startup — so there is nothing else to
wire:
import { ColumnType, createGrid } from "@agility-workbench/grid";
const api = createGrid(document.getElementById("app")!, {
rowIdKey: "id",
columnDefs: [
{ key: "name", label: "Name", type: ColumnType.STRING },
{ key: "price", label: "Price", type: ColumnType.NUMBER },
],
rowData: [{ id: 1, name: "Widget", price: 9.99 }],
});
// Later, when the host view goes away:
api.destroy();The container must have an explicit height. Beyond columnDefs and rowData, the second
argument accepts every GridOptions field, and everything after creation
happens through the returned api.
Customizing menus
Menu items are configuration, not plumbing — you do not need adapters or a manual lifecycle for them.
Column menu — set columnMenu on a column, or on defaultColDef to cover every column. It
receives the items the grid built and returns the items to show, so you can extend, reorder,
filter, or replace them. Setting it to false removes the column's menu entirely: no ⋮ button and
no header right-click.
createGrid(host, {
columnDefs: [
{
key: "price",
label: "Price",
columnMenu: ({ column, items }) => [
...items,
{ isSeparator: true },
{ label: "Audit", left: "my-icon-class", onClick: () => audit(column.colId) },
],
},
{ key: "actions", label: "", columnMenu: false },
],
// Every other column gets this one:
defaultColDef: { columnMenu: ({ items }) => items.filter(i => i.command !== "column.hideMany") },
});Items are plain objects: label, left (an icon CSS class or an element), right, onClick,
subMenu, isSeparator, isLabel, disabled, title. An onClick you supply takes precedence
over the built-in command an item would otherwise run, and returning an empty array opens no menu.
isLabel marks an item as static text rather than a command — a caption for the items around it.
It can appear anywhere in a menu (or a subMenu), as often as you like. It is not focusable and
not clickable, so keyboard navigation skips it and onClick/command are ignored; only label,
left, right, and id mean anything:
columnMenu: ({ items }) => [
...items,
{ isSeparator: true },
{ isLabel: true, label: "Danger zone" },
{ label: "Reset column", onClick: resetColumn },
]The getter runs only when the menu targets that column alone. When several columns are selected
the built-in items act on the whole set, so no single column's configuration governs them — the
grid-level multiColumnMenu handles that case instead:
createGrid(host, {
columnDefs,
multiColumnMenu: ({ columns, items }) => [
...items,
{ label: `Export ${columns.length} columns`, onClick: () => exportCols(columns.map(c => c.colId)) },
],
});Return [] for no menu.
A multi-column menu opens with a caption naming its scope — the column names while the list is
short, a count beyond that — so it can never be mistaken for a menu about the header it is anchored
to. The caption is an isLabel item with the id selectionScope, so a getter can relabel or drop
it like any other item:
multiColumnMenu: ({ columns, items }) =>
items.map(i => (i.id === "selectionScope" ? { ...i, label: `Editing ${columns.length} fields` } : i)),Which menu you get. Both entry points — the ⋮ button and a header right-click — settle on the same scope: the menu acts on the current column selection when the column you clicked is part of it, and on that column alone otherwise. Opening a menu from outside your selection therefore replaces the selection rather than silently acting on columns you did not click. A group header's menu always covers its leaves, by either gesture.
multiColumnMenu: false disables multi-column menus outright. Note what false cannot do here,
unlike columnMenu: false: whether a menu is multi-column is only known once it is opening, after
the grid has claimed the gesture — so opening one from inside a multi-selection shows no menu at
all rather than the browser's.
Body context menu — bodyContextMenu does the same for right-clicks in the grid body, and
takes false to let the browser's native menu through:
createGrid(host, {
columnDefs,
bodyContextMenu: ({ ctx, items }) => [...items, { label: "Copy report link", onClick: () => share(ctx) }],
});The adapters below exist for one thing the getters above cannot do: mounting framework components
inside menu items and unmounting them when the menu closes (cleanup). That is how the React and
Angular bindings work; a plain host rarely needs it.
import { createGrid, type IMenuAdapter } from "@agility-workbench/grid";
const menus: IMenuAdapter = {
resolveMenuItems: (ctx, defaults) => {
// An item whose icon is a live component: mount it now, unmount it in cleanup.
const badge = mountBadge(ctx.targetColId);
return {
items: [...defaults, { label: "Sync status", left: badge.el }],
cleanup: () => badge.unmount(),
};
},
};
const api = createGrid(document.getElementById("app")!, {
rowIdKey: "id",
columnDefs: [{ key: "name", label: "Name", type: ColumnType.STRING }],
rowData: [{ id: 1, name: "Widget" }],
menuAdapter: menus,
// bodyMenuAdapter: … the same for the body context menu
});Both options are optional; omitting them yields the built-in menus. An adapter runs after the
getters above and receives their result as its defaults. When the adapter is not known at
creation time, api.registerMenuAdapter(menus) / api.registerBodyMenuAdapter(menus) install one
on a mounted grid (pass null to remove it); the change applies to the next menu open.
Quick filter
One search box over every visible column, client-side. quickFilter: true takes the defaults; an
object configures the matching and the box itself:
const core = new GridCore(new CanvasMeasurer(), {
quickFilter: {
mode: "onDemand", // Ctrl/Cmd+F opens the box; "always" pins it open
matchMode: "multiTerm", // "substring" | "wholeCell"
caseSensitive: false,
debounceMs: 150,
showOptions: true, // the match-mode / match-case popover
showLayoutOptions: false, // ...plus Anchor and "Keep filter when closed"
clearOnClose: true, // false leaves a dismissed search running behind a re-openable pill
position: { anchor: "right", offsetX: 8, offsetTop: 6 },
},
});
api.setQuickFilter("emea on track");
api.setQuickFilter("42", { matchMode: "wholeCell" });
api.getQuickFilterText();Every comparison is against a cell's formatted text, so the user searches what they see —
$1,200.00 included. multiTerm (the default) needs every whitespace-separated word, substring
needs the input as one contiguous run inside a cell, and wholeCell needs a cell's entire text:
the exact lookup a global search otherwise lacks — "the row whose id is exactly 42", without
knowing which column holds it. Options the widget exposes are sticky for the session, not written
back to grid options.
Without a toolbar the box floats inside the grid, in the strip below the header and against its
right edge; position moves it, and toolbar: { quickFilter: true } hands it to the
toolbar instead, where the bar owns the layout and position / clearOnClose /
showLayoutOptions no longer apply. Quick filtering is client-side: server-side applications
receive structured column filters in each data-source request.
Find instead of filter
behavior: "find" turns the same box into Excel's Find. Nothing is filtered — every row stays
where it is and each cell whose own text matches is highlighted, with the match count inside the
box (3/27, the active match over the total) and previous/next steppers beside it.
quickFilter: {
behavior: "find", // "filter" (default) | "find"
showBehaviorToggle: true, // let the end user switch; omit to force `behavior`
}api.setQuickFilter("smith", { behavior: "find" });
api.findNext(); // → { rowId, colId, colInstanceId } | null
api.findPrevious();
api.getFindState(); // { behavior, available, text, matchCount, activeIndex, activeMatch }Both behaviors share the text, matchMode and caseSensitive, so switching re-runs the same
search the other way. A match is scoped to one cell, since a highlight has to land on one, which
is the only place multiTerm reads differently: filtering lets its words fall in different cells of
a row, finding needs them all in the cell it highlights, so john smith finds Smith, John.
Matches are counted over the whole client-side view — every page, and rows inside collapsed groups
— and they count cells, so a term in three columns of one row is three separately reachable
matches.
Ctrl/Cmd+F opens the box, Enter and Shift+Enter step forward and back (wrapping at both
ends), and Escape dismisses it. Focus stays in the box throughout, so the cell cursor and the
selection are left alone — stepping matches is a search gesture, not a navigation one.
Stepping to a match reveals it: expanding its ancestor groups, paging to it, scrolling it into
view, and — because the floating box is a control surface the user keeps operating while reading
the cells under it — moving that box out of the way when nothing else can. The grid scrolls the
match clear where it can; where no scroll can (the first row, the last column, a pinned column, a
row docked in a frozen band) the box flips to the grid's other edge for as long as the search
lasts, without rewriting position.anchor.
Finding needs data-row cells to highlight, so it is unavailable on the server-side row model and
while the pivot layout is displayed (there, every displayed row is a group row). In both cases
getFindState().available is false and the search falls back to filtering rather than going inert.
Group rows, the auto-group and tree columns, utility columns and hidden columns are never searched.
Subscribe to quickFilterFindChanged for an app-owned counter; a finding quick filter deliberately
does not fire filterChanged, because no rows moved. --pte-find-match-bg-color,
--pte-find-match-active-bg-color and --pte-find-match-active-border-color tint the highlights,
or set all three from one color with the findMatchColor theme
parameter.
Toolbar
Toolbar sections are individually opt-in. There is no separate visibility flag: the toolbar appears when at least one section is enabled, and disappears when none are enabled.
const core = new GridCore(new CanvasMeasurer(), {
toolbar: {
grouping: true,
sorting: true,
quickFilter: true,
views: true,
export: true,
pivot: true,
},
});All six sections default to false. toolbar.quickFilter hosts the quick
filter in the toolbar; the separate quickFilter option still configures matching,
case sensitivity, and debouncing, while floating-only placement and close behavior do not apply
there. If quickFilter
is omitted, enabling the toolbar section uses its defaults. The React binding applies section
changes live without remounting the grid. A column panel configured with trigger: "toolbar" also
keeps the toolbar visible for its Columns button, independently of these section flags.
toolbar.pivot adds the pivot-mode indicator and toggle (client-side row model only) — see
Pivot mode.
Narrow bars
The toolbar and the footer both measure their own rendered width — the grid's container, not the
browser window — and cope with a width their controls do not fit by the same rule: nothing is
clipped, overlapped, or compressed. Every control is laid out at its natural size in one of its
presentation stages, or it moves into that bar's overflow menu (⋮); a bar that runs out of
stages scrolls, so no control becomes unreachable.
The toolbar's ladder runs cheapest first: button captions go (each button's tooltip carries the
label it lost), then the quick-filter input narrows, then Grouped by / Sort by chips fold
from the end into a +N, then each chip section becomes a summary button (Grouped by 3) that
opens the same drag-reorder editor as a popover and stays a drop target for a column dragged out
of the header, then Export / Pivot / Views and the chip sections move into the ⋮, then the
quick filter becomes a search icon that expands back over the bar in place — and Columns last,
because the column panel is the escape hatch to everything else. Two rules keep it usable: a
control holding focus or carrying live state (a quick filter with a query) is not displaced — the
next rung goes instead — and when something is displaced the ⋮ wears a dot while anything
inside it is active, with focus following the displaced control to the button that now holds it.
The order is fixed; an application that wants a different one configures fewer controls rather than re-ordering the ladder. What is configurable is whether the ladder runs at all:
const options = {
toolbar: { grouping: true, sorting: true, quickFilter: true, responsive: "collapse" },
paginationControls: { responsive: "collapse" },
};"collapse" (the default) walks the ladder above. "scroll" leaves every control at full size
and scrolls the bar as soon as they do not fit. false lays the bar out and lets it clip, for an
application that guarantees its own width. paginationControls.responsive says the same for the
footer, whose own ⋮ holds rows-per-page, the aggregate scope, and the sheet strip's +. On the
server-side row model the scope's Entire dataset choice needs a server aggregation source (the
data source's getAggregates, or serverSideAggregationSource); until one exists it is greyed out
with a tooltip, or hidden, as paginationControls.aggregateScope says.
Saved views
Enable toolbar.views and provide an application-owned list plus persistence callbacks:
const options = {
toolbar: { views: true },
savedViews: {
views,
activeViewId,
onChange: nextViews => persist(nextViews),
onActiveViewChange: id => setActiveViewId(id),
},
};Views capture column layout, row grouping, multi-sort, column filters, quick-filter text, and group
expansion. api.captureViewState() and api.applyViewState(state) expose the same serializable
state workflow programmatically. Applying a view restores columns exactly by default; pass
{ columns: "merge" } to retain columns added after capture.
Column panel
Enable the built-in right-side column panel to let users search, show/hide individually or in bulk, pin, and reorder columns. Bulk visibility applies to the current search results and skips non-hideable columns. Changes apply immediately; Reset restores the layout captured from the latest column definitions. The footer marks a changed layout as Modified and enables Reset only while drawer-managed visibility, pinning, or order differs from that baseline.
const core = new GridCore(new CanvasMeasurer(), {
columnPanel: {
trigger: "toolbar",
defaultOpen: false,
width: 320,
},
});Pass columnPanel: true for the default right rail. Every trigger opens the same right-hand drawer:
| Trigger | Entry point |
| --- | --- |
| "rail" | Full-height collapsed rail on the right (default) |
| "header" | Empty full-height right gutter, with the toggle in its header corner |
| "menu" | Manage columns… in the column button and header context menus |
| "footer" | Empty full-height right gutter, with the toggle in its footer corner |
| "toolbar" | Grid toolbar above the header, button at the extreme right |
Reordering works by drag-and-drop and through accessible Move up/down controls. Trigger and width
changes are applied live by the React binding without remounting the grid. Set
suppressColumnPanel: true on a column definition to keep that column in the grid and API while
omitting it from the drawer and its bulk operations. A polite live region announces visibility,
pinning, ordering, and reset results to assistive technology. Nested column definitions render as
collapsible groups; searching a group name reveals its descendants, and reordering stays within the
appropriate sibling group. Columns conditionally shown by columnGroupShow reflect their effective
visibility as the header group expands or collapses; group-hidden columns are excluded from bulk
visibility and their individual checkbox explains which parent currently controls them.
Pivot mode
Pivot mode (client-side row model) turns the grid into a pivot table over its own rows: row
groups down the side, one generated column group per distinct value of each pivot column, and one
read-only, sortable leaf per value aggregate underneath. Filters and the quick filter keep running
on the source rows, cell edits re-derive the pivot live, and the three roles are ordinary grid
state — row groups, the aggregate model (a column may carry several aggregate types at once), and
the new pivotColumns list:
import { AggregateType, createGrid } from "@agility-workbench/grid";
const api = createGrid(host, { rowIdKey: "id", columnDefs, rowData });
api.setRowGroupColumns(["region"]);
api.setPivotColumns(["quarter"]);
api.setAggregates([{ colId: "revenue", type: AggregateType.SUM }]);
api.setPivotMode(true);Users reach the same state through the column menu ("Pivot on Column", per-type aggregate
toggles), the toolbar's pivot section (mode indicator + toggle), and the column panel, which
becomes the pivot setup while pivoted: three ordered field wells — Row groups / Column labels /
Values — each drag-reorderable, with a Values entry's aggregate function picked in place by
clicking it. Outside pivot mode, panel rows wear removable role chips that read the recipe back.
columnPanel: { availability: "pivot" } mounts that panel only while pivoted — the pivot
customizer without the column-management drawer — for apps that manage columns their own way.
Turning the mode off restores the exact pre-pivot grouping and aggregates; turning it back on
reinstates the last pivot session.
Pivot mode with no row group, no pivot column and no value displays nothing at all — no
columns, not even the auto-group column — and shows an empty state instead, whose copy is the
app's through pivotEmptyMessage. That is the state a fresh pivot sheet opens on, and
api.isPivotUnconfigured() reports it, so an app driving pivot through its own UI can react to
the same state the grid does. Where the column panel is enabled, entering pivot mode with nothing
configured opens it, since a blank canvas has no header to reach a column menu from. Filling any
one of the three roles ends the blank state: a partly configured pivot is not blank — row
groups alone show the group tree, a value alone the grand-total row, and a pivot column alone the
group tree under the "no values" header hint (pivotNoValuesMessage).
Generated columns take their formatting from the source value column (overridable via
pivotResultColumnDef), get stable ids so widths and sorts survive data changes, and can be
dragged in two modes (pivotColumnMoveMode): "measures" reorders the value measures
consistently across every group, "free" arranges leaves and whole groups. maxPivotColumns
caps the generated set (whole pivot values at a time), firing the latched
pivotColumnLimitReached event instead of failing. Pivot state — including everything a sheet
needs — rides the ordinary view-state capture/apply, and CSV/Excel export writes the generated
nested headers with every group row.
Sheets
Supplying sheets renders a spreadsheet-style tab strip in the footer (tabs left, aggregation
center, pagination right). One grid, one row model, one edit history — each sheet is a named, live
view state (columns, sort, filters, grouping, aggregates, pivot config, expansion, page), and
switching tabs captures the outgoing sheet and applies the target. The contract mirrors
savedViews: the application owns the list, the grid reports changes back.
const api = createGrid(host, {
rowIdKey: "id",
columnDefs,
rowData,
sheets: {
sheets: [{ id: "data", name: "Data" }],
activeSheetId: "data",
onChange: (sheets) => save(sheets),
onActiveSheetChange: (sheetId) => remember(sheetId),
},
});+ appends a fresh pivot sheet; tabs rename inline (double-click / F2) and carry a context menu
with Rename / Change color / Duplicate / Delete. Ctrl+PageDown / Ctrl+PageUp switch to the
neighboring sheet, and the strip is a full ARIA tablist with roving tabindex. Tab colors land on
GridSheet.color and render as a tint, so any CSS color stays legible in both themes; the palette
is replaceable (per sheet, if needed) through SheetsOptions.colors, and
SheetsOptions.customColor adds the platform color picker.
Sparklines
SparklineRenderer plots the array returned as the cell value. Keep data selection in the
column's valueGetter and presentation options in cellRendererParams:
import {
SparklineRenderer,
type SparklineTooltipValueFormatterParams,
} from "@agility-workbench/grid";
const trendColumn = {
colId: "trend",
label: "Trend",
valueGetter: (row) =>
row.data.monthlyRevenue.map((value, index) => [`Month ${index + 1}`, value]),
cellRenderer: SparklineRenderer,
cellRendererParams: {
type: "line",
showPoints: true,
tooltipValueFormatter: ({ xValue, yValue }: SparklineTooltipValueFormatterParams) =>
`${xValue}: ${yValue.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}`,
},
};The renderer accepts number[] (array indexes become X values) and [x, number][] data, plus
line, area, and bar types. Tuple X values are treated as ordered categories. Set
showPoints: true to draw visible markers on line and area charts. Individual points use the
grid's tooltip layer; each line/area point owns a full-height nearest-X hover band, so the pointer
does not need to hit the marker exactly. Grid-level tooltip options such as delays, positioning,
and disabling tooltips continue to apply.
Pinned and sticky rows
Application-owned rows can be frozen above or below the virtualized body. They use the normal columns, value formatters, cell renderers, and row/cell styling, but stay outside sorting, filtering, grouping, pagination, selection, and the displayed row count:
const core = new GridCore(new CanvasMeasurer(), {
pinnedTopRowData: [{ label: "Target", amount: 1_000_000 }],
pinnedBottomRowData: [{ label: "Total", amount: 842_000 }],
});Replace either band live with api.setPinnedTopRowData(rows) /
api.setPinnedBottomRowData(rows).
Generated group nodes can move into either band without leaving a second body copy. They remain in the row model, so their chevrons, hierarchy position, and aggregate values stay connected:
const options = {
groupRowsSticky: true,
isRowPinned: ({ node }) =>
node.isGroup && node.groupKey === "EMEA" ? "bottom" : null,
};
api.setRowPinned(groupNode.id, "top");
api.setRowPinned(groupNode.id, null); // unpingroupRowsSticky stacks the expanded ancestors of the first visible row at the top as the body
scrolls, with position: sticky semantics: the original rows never leave the body flow, ancestors
are mirrored into an overlay clipped to the top of the body, and an arriving sibling header pushes
the outgoing one up behind its parent instead of swapping in place. Because the overlay is
absolutely positioned, sticky transitions never resize or shift the body. The chain docks at rest
(scrollTop 0) directly over its pixel-identical rows, so the band never has to "appear" mid-scroll
even when the compositor presents scrolled frames ahead of the main thread; wheel gestures over
the band are forwarded to the grid scroller. It supports
singleColumn, multipleColumns, and full-width groupRows display. Application-pinned rows use
separate top/bottom bands outside the body; each band caps at 30% of the grid height and gets an
independent vertical scrollbar when its content exceeds that space, while the central body keeps
its own scrollbar. Pinned cells retain section-local row and global column coordinates, so arrows
navigate top → body → bottom exactly as horizontal navigation crosses left → center → right column
sections. The body always scrolls to its content edge first: only a plain arrow step from the
first/last body row hands the active cell over to a band, and Ctrl/Home/End/Page jumps are
region-locked. Range selection spans the bands: a range is one contiguous span of the unified
pinned top → body → pinned bottom row sequence (built by drag or Shift+Arrow across the edges),
each segment paints its own selection rectangle, and copy serializes the segments in that order.
Ctrl+A selects the entire sequence, bands included. Exports mirror the same order: pinned data
rows frame the body in CSV and Excel output (full exports and selection exports alike, honoring a
range's pinned segments), and the Excel export freezes the header together with the pinned top
rows so they stay pinned in the workbook; the aggregate footer keeps aggregating body rows only.
Cut, clear, and paste apply to the body segment only. Pinned rows are read-only by default; pinnedRowsEditable: true enables inline
editing of application-pinned data rows (writing into the provided data objects, with undo/redo)
and pinned tree-data parents — synthetic group headers are never editable. Scrolling a focused row
into view accounts for the sticky ancestor overlay, so the row lands below the docked chain rather
than hidden underneath it.
Tree data
Tree data supports four explicit relationship modes: three client-side ones, and "server" on the
server-side row model. The client-side modes share expansion, sibling sorting, ancestor-preserving
filtering, selection, editing, saved-view expansion, sticky ancestors, and export behavior. Tree
data cannot be combined with column-value row grouping (or pivot mode) on either row model, and a
relationship mode belongs to exactly one row model — the mismatched pair warns and drops the
option.
Use complete paths when rows arrive as a flat hierarchy. Missing prefixes become deterministic synthetic ancestors:
const options = {
rowIdKey: "id",
treeData: {
mode: "path",
getPath: row => row.path, // ["World", "Europe", "France", "Paris"]
},
};Use parent references for database-shaped flat records. Input order is irrelevant; null or
undefined creates a root:
const options = {
rowIdKey: "id",
treeData: {
mode: "parent",
getParentId: row => row.parentId,
getLabel: row => row.name,
columnDef: {
label: "Organization",
width: 280,
},
},
};Use nested children when rowData already contains root objects with nested arrays:
const options = {
rowIdKey: "id",
treeData: {
mode: "children",
getChildren: row => row.children,
getLabel: row => row.name,
},
};Use mode: "server" on the server-side row model when the hierarchy is too large (or too deep) to
ship: each row says whether it has children, and a parent's children are requested the first time it
is expanded, through IServerSideRequest.treeParent (the parent's row id, its root→parent row-id
path, and its row object). Ragged siblings and unbounded depth cost nothing up front:
const options = {
rowModelType: "serverSide",
rowIdKey: "id", // must be unique across the WHOLE tree, not per parent
serverSideDataSource,
treeData: {
mode: "server",
hasChildren: row => row.kind === "folder",
getLabel: row => row.name,
// Naming the server's own label field puts hierarchy-column sorts/filters on the wire under it.
columnDef: { label: "Path", key: "name", width: 340 },
},
};
// One row's subtree — its children listing and everything below it — reloads on demand:
await api.refreshServerSideData({ rowId: "folder-42", purge: true });Server tree rows are ordinary data rows (selectable, editable, copyable) with the same chevrons,
indentation, expansion state, sticky ancestors, and hierarchy keyboard mode. Filtering is the
server's responsibility, ancestor preservation included, and export writes the rows the client holds
in the shape on screen — an expanded parent with the children the grid has fetched, a collapsed one
alone, nothing from blocks never requested.
A children block that breaks the id rule in a way the grid can be certain of — a row repeating one
of its own ancestors' ids, or one response listing an id twice — is rejected whole and reported
through the error event (code: "row_model_error") instead of entering the store. The event's
details is a ServerSideDataError (reason, rowId, parentId, path, row), and
isServerSideDataError(ev.details) tells it apart from a data source's own error().
Real rows remain data-bearing and editable even when they own children. Duplicate ids, duplicate
paths, and relationship cycles throw descriptive errors. A missing parent-id reference is rendered
as a root with a console diagnostic. In nested-children mode, transaction additions may add root
subtrees and removing a parent removes its subtree; use setRowData when changing the parent of an
existing nested row. Tree data uses one generated hierarchy column whose normal column definition
can be supplied through treeData.columnDef. It is unpinned by default and participates in ordinary
column movement, sorting, filtering, visibility, pinning, menus, and export. groupDisplayType
continues to apply only to column-value row grouping.
Column-value row grouping has the same escape hatch: the auto-generated group column shown in
groupDisplayType: "singleColumn" is an ordinary column — unpinned, movable, resizable, and
sortable by default — and groupColumnDef layers a normal column definition (label, width,
pinned, movable, resizable, sortable, …) over those defaults. Sorting it orders the group
buckets at every grouping level. Its identity and grouping-machinery fields (colId, key,
children, groupable, aggregatable, filter) are grid-owned and cannot be overridden.
Tree data has two keyboard-navigation modes:
treeData: {
mode: "parent",
getParentId: row => row.parentId,
keyboardNavigationMode: "hierarchy",
enableKeyboardNavigationModeSwitch: true,
}"grid" (the default) preserves the normal Ctrl/Cmd+Arrow data-block jumps. In "hierarchy"
mode, Ctrl/Cmd+Right expands, Ctrl/Cmd+Left collapses an expanded parent (or focuses the direct
parent from a leaf/already-collapsed parent), and Ctrl/Cmd+Up always focuses the direct parent when
the hierarchy column is active. Ctrl/Cmd+Shift+Arrow retains grid range/block navigation.
When enabled, the fixed Ctrl/Cmd+Shift+Space shortcut switches modes at runtime wherever the
keyboard cursor is, header included. Applications can also call api.getKeyboardNavigationMode() and
api.setKeyboardNavigationMode(mode), which work wherever the cursor is.
Both fields are reconfigurable on a mounted grid — they are the only part of treeData that is,
since the relationship mode and its accessors decide the row shape:
api.setTreeDataKeyboardNavigationOptions({ enableKeyboardNavigationModeSwitch: true });Only the fields you pass change. A mode set this way reports source: "options" on
keyboardNavigationModeChanged, distinguishing configuration from the imperative
setKeyboardNavigationMode ("api") and the shortcut itself ("shortcut").
Keyboard bindings
Bindings live in a table rather than in nested ifs, resolved by scope: an open cell editor and a
focused embedded control own their keyboard completely; otherwise the header cursor is consulted
before the body cursor, and whole-grid chords last. A chord may therefore mean different things
depending on where the cursor is, and modifiers are matched exactly — Ctrl+C is copy, while
Ctrl+Shift+C is left to the browser.
The header cursor (the header is row 0 of the grid — ArrowUp off the first row reaches it):
| Key | Action |
| --- | --- |
| Arrow← / Arrow→ | previous / next column |
| Ctrl/Cmd+Arrow← / Ctrl/Cmd+Arrow→, Home / End | first / last column |
| Arrow↓ | hand the cursor to the first row |
| Space | select the column, or select all rows from a utility header |
| Ctrl/Cmd+Space | add the column to the selection (toggle) |
| Shift+Arrow← / Shift+Arrow→, Shift+Home / Shift+End | extend the column selection |
| Enter | sort the column, or toggle a group expander / select-all header |
| Ctrl/Cmd+Enter | add the column to a multi-column sort |
| Shift+Enter | sort every column in the selection at once |
| Alt+Arrow↓ / Shift+Alt+Arrow↓ | open the column menu / the column filter |
Space selects and Enter sorts: one job per key, matching the body, where Space on a checkbox cell
already means "select this thing". Shift+Arrow extends the column selection from the cursor's
column and does nothing at all — not even move the cursor — unless the cursor is on a selected
column, so it can never read as plain movement that quietly starts selecting. Column ranges
materialize in display order; individually toggled columns keep the order they were added in.
Only a plain arrow crosses the header/body boundary, in both directions: ArrowUp off the first row
reaches the header and Arrow↓ returns to the rows, while Ctrl/Cmd+Arrow↑ from the first row
block-jumps within the body and Ctrl/Cmd+Arrow↓ on the header does nothing at all. Ctrl/Cmd in
the header is therefore only ever a move along the row of columns, and there it is a plain edge jump
to the first/last column — a header cell has no value to scan, so the body's content-aware block jump
has nothing to mean.
The body cursor keeps the spreadsheet conventions: arrows move, Ctrl/Cmd+Arrow jumps a block,
Shift extends a range, Home/End reach the row edge (+Ctrl/Cmd a grid corner), PageUp/
PageDown move a viewport, F2/Enter edit, Shift+F2 opens the cell's action frame, printable
characters start an edit, and Ctrl/Cmd+A/C/X/V/Z/Y do what they do everywhere.
Alt+Arrow is deliberately not claimed, so the browser keeps its back/forward gesture.
Whole-grid chords are last in that resolution order: Ctrl/Cmd+F opens the quick
filter, where Enter / Shift+Enter step find matches and Escape dismisses the
box — the search owns its own keyboard while focus is inside it, so typing there never reaches a
cell editor.
Two options decide how much of this keyboard surface exists. cellSelection governs the body
cursor: with false (inert cells) or "text" (native text selection), the body scope goes dark as
a unit — navigation, paging, select-all, clipboard, editing keys, and keyboard row selection all
operate on or through the cursor, so none of them means anything without it. headerKeyboardNavigation
(default true) governs the header cursor the same way; turning it off makes sorting, column
selection, and the column menu mouse-only — which also makes them unreachable for keyboard and AT
users, so leave it on unless the grid is deliberately inert. Both reconcile live through
updateGridOptions.
Application shortcuts
api.registerShortcut adds an application binding to the same table, per grid instance — it fires
only while focus is inside that grid:
const off = api.registerShortcut({
id: "approve",
chord: "mod+shift+y", // mod = Ctrl on Windows/Linux, Cmd on macOS
label: "Approve the selected rows",
when: () => hasApprovableSelection(),
run: () => approveRows(api.getSelection()),
});
// later (framework cleanup — disposing twice is safe):
off();Application bindings resolve after every built-in, so they can never shadow one by accident;
override: true registers ahead of the non-blocking built-in scopes instead, for chords the
application consciously takes over (mod+f, the clipboard triple). An open cell editor or a
focused filter input still owns the keyboard completely, override or not.
Some chords are reserved and refused with a thrown error — but reservation is a predicate over
the live configuration, not a static list. Tab (focus traversal) and Escape (overlay dismissal) are
reserved always. The navigation cluster — arrows, Home/End, Enter, Space, and for the body
PageUp/PageDown — is reserved, under any modifiers, while a surface that uses it is on:
disable cellSelection and headerKeyboardNavigation and a display-only grid frees all of them
for the application. A shortcut registered while a feature was off goes dormant if the feature is
later re-enabled (the built-in wins again) and wakes when it is turned back off.
mod+alt+<printable> chords are refused outright: Windows AltGr reports as Ctrl+Alt, so such a
shortcut would fire while a user merely types an accented character.
Menus can show accelerators in two ways. A built-in item whose command has a keyboard binding
shows that binding's chord automatically (Copy shows Ctrl+C / ⌘C), so the menu and the keymap
cannot drift. Application items take shortcut: "mod+shift+y" as a display hint — menus are
built per open, so nothing is harvested from item lists; register the real binding separately and
write the same chord in both places. An explicit right slot (and the submenu arrow) wins over the
accelerator.
Keyboard-shortcut discovery
api.getKeyboardShortcuts() returns the whole binding table — built-ins and application shortcuts,
innermost scope first — as { id, scope, chord?, label?, command? } rows. Format a row's chord for
the user with the exported formatChord(chord): ⇧⌘K on macOS, Ctrl+Shift+K elsewhere, arrow
glyphs on both. Pattern bindings (type-to-edit) appear without a chord.
A grid-owned shortcut reference panel is still planned: it should show the shortcuts valid for
the current context — active navigation mode, focused area, selection and editing state, enabled
features — rather than a static global list, be reachable from within the grid by keyboard and
pointer, and update when runtime options or focus change. getKeyboardShortcuts() is what makes it
buildable as a filtered view rather than a hand-maintained list.
Styling
Nothing to do — the grid delivers its own stylesheet when it attaches, once per document, and once per shadow root for grids inside one. There is no CSS import to remember and no unstyled-grid failure mode.
Two cases need a little more:
Strict Content Security Policy. Injection into a document uses a <style>
element, which needs style-src 'unsafe-inline' or a nonce. If your CSP has
neither, pass one:
{ styleNonce: "per-request-random-value" }Nonces are page-global, so give every grid on the page the same value. Grids
inside a shadow root need no nonce — those are styled via CSSOM, which CSP's
style-src does not cover.
Loading the stylesheet yourself. Opt out and import it instead:
import "@agility-workbench/grid/styles.css";
// and on every grid:
{ suppressStyleInjection: true }Opting out matters if you do this: without it both copies apply, and the injected one sorts later in the cascade, so it would start winning over overrides you wrote against the imported sheet. This path also suits build-time CSS tooling such as critical-CSS extraction, which cannot see injected styles.
injectGridStyles(target?, { nonce }) remains exported if you want to place the
stylesheet yourself, ahead of the first grid mounting. It is idempotent and a
no-op during SSR.
Theming
Themes are immutable objects that resolve to CSS custom properties applied inline on each grid instance — so two grids on the same page can look completely different, and there is no global CSS to override.
Start from a preset and refine with withParams:
import { themeLight, themeDark } from "@agility-workbench/grid";
const myTheme = themeLight.withParams({
accentColor: "#2563eb", // fans out to selection, checkbox, spinner, filter-active…
backgroundColor: "#ffffff",
rowHeight: 44,
spacing: 10,
fontFamily: "Inter, sans-serif",
});Semantic params + escape hatch
High-level params (accentColor, borderColor, spacing, rowHeight, …) each fan out to
several low-level variables. For anything not covered, set any grid CSS variable directly via
vars — these are typed and autocompleted:
themeDark.withParams({
accentColor: "#22d3ee",
vars: {
"--pte-scrollbar-thumb-color": "#475569",
"--pte-selected-bg-color": "#0e7490",
},
});Icons
Override any icon with a URL, data URI, or inline SVG string, via options.icons or the
theme's icons:
new GridCore(new CanvasMeasurer(), {
icons: { filter: "<svg viewBox='0 0 24 24'>…</svg>" },
});Entry points
| Import | Contents |
| --- | --- |
| @agility-workbench/grid | Framework-agnostic core, public enums, event payload types, theming API |
| @agility-workbench/grid/styles.css | The base stylesheet (icons inlined). Optional — the grid injects it automatically; see Styling |
Public enums and event payload types are available from the package entry point:
import {
AggregateType,
ColumnType,
FilterType,
type GridEventEditingChangedParams,
} from "@agility-workbench/grid";