npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agility-workbench/grid

v1.3.0

Published

A high-performance, framework-agnostic TypeScript data grid for building modern data workspaces.

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 .xlsx writer).
  • Themeable via an AG-Grid-style theme object that resolves to CSS variables applied per grid instance.

Installation

npm install @agility-workbench/grid

For React apps, install the binding instead (it depends on this package):

npm install @agility-workbench/react-grid react react-dom

For Angular 20.3+ apps, install the Angular binding instead (it also depends on this package):

npm install @agility-workbench/angular-grid

Quick 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); // unpin

groupRowsSticky 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";

License

MIT