# av-grid API reference A dependency-free, framework-agnostic virtualized data grid that renders straight to the DOM. Built for 100,000+ rows with no lag while scrolling, selecting or editing. This page is the complete public surface. It is written to be read once, mid-task, to answer one question — so it is exhaustive and skimmable, and every entry carries the snippet you would actually type. Runnable standalone files live in [`../examples/`](../examples/). > **av-grid is not AG Grid.** The names differ by one character and nothing else does: av-grid > shares no API, no options and no lineage with it. There is no `columnDefs`, no `rowData`, no > `createGrid`, no module registration. If you are filling a gap from memory, fill it from this > page — a half-remembered AG Grid call is *plausible and wrong*, which is the expensive kind of > mistake. The UMD global is `AVGrid`, capital V. **Contents** - [Install and the minimum call](#install-and-the-minimum-call) - [The naming decision](#the-naming-decision) - [`AVGrid` statics](#avgrid-statics) - [Options](#options) - [Columns](#columns) - [What is inferred](#what-is-inferred) - [Instance methods](#instance-methods) - [Callbacks, at a glance](#callbacks-at-a-glance) - [Filtering](#filtering) - [The filter bar](#the-filter-bar) - [The context menu](#the-context-menu) - [Keyboard reference](#keyboard-reference) - [Theming](#theming) - [DOM contract](#dom-contract) - [Primitives](#primitives) - [Helpers](#helpers) - [The engine](#the-engine) - [Errors and warnings](#errors-and-warnings) - [Performance notes](#performance-notes) --- ## Install and the minimum call ```html
``` That is the whole minimum call. Columns, header labels, widths, data types, alignment and row keys are all inferred from the rows — see [What is inferred](#what-is-inferred). **The host needs a height.** The grid measures its own root to decide what is on screen, so that height has to be *definite*. A host with a height (`height: 400px`, a flex child, `position: absolute`) gets a grid that fills it; a host with no height at all gets a pixel fallback, and the grid re-measures once on the next frame in case the page simply had not laid out yet. If a grid renders blank, `grid.getState().viewport.width` is the first thing to read — `0` is the answer to the commonest integration failure. **Builds.** `dist/av-grid.js` (ESM), `dist/av-grid.umd.cjs` (UMD, global `AVGrid`), `dist/index.d.ts` (types), `dist/av-grid.css` (the stylesheet, if you would rather link it than let the grid inject it). ```html ``` The stylesheet is injected into the document on first use and shared by every grid in it. Pass `injectStyles: false` and link `av-grid.css` yourself if you would rather control it. --- ## The naming decision **The vocabulary is `key` / `name`, and there are no aliases.** ```js columns: [{ key: "score", name: "Score" }] // ✅ columns: [{ field: "score", title: "Score" }] // ❌ — not accepted, not aliased ``` Why, since Tabulator's `field` / `title` is at least as familiar: - **`key` is already the property name.** It is what `row[column.key]` reads, what a filter names in `columnKey`, what `onEdit` reports, and what `deleteColumns()` takes. Calling the same string `field` in the column and `columnKey` everywhere else would be two words for one thing. - **The reference uses it**, and the port's models are kept close to the reference so behaviour can be diffed against it. - **`name` beats `title`** because `title` is an HTML attribute with a different meaning (the tooltip), and a column literal carrying both would read as a contradiction. - **No aliases, deliberately.** Two ways to spell one thing is worse than either alone: examples drift between them and generated code becomes inconsistent. So `field` and `title` are silently ignored rather than accepted, and `validateColumns` raises on a column with no `key` at all. The reference's misspellings are **not** carried over. Type the correct spelling: | Reference | Here | |---|---| | `haderRenderer` | `headerRender` | | `cellRenderer` + `cellFormater` | `render` (one hook) | | `editFormater` | `editor` (a `CellEditor` factory, not a renderer — see [`editor`](#editor--a-custom-cell-editor)) | | `editRender` | `editor` — this library's own earlier name for the same field, removed because it could not work: it was handed a `CellContext`, so whatever it drew had no way to record a value, commit it or cancel | | `resizible` | `resizable` | | `dataAlignment` | `align` | | `TSortColumn`, `TFilter`, … | `SortColumn`, `Filter`, … (no `T` prefix) | One flat options object, no required call order: anything you can pass to `create()` you can pass to `setOptions()` later, and the two run the same code. --- ## `AVGrid` statics ### `AVGrid.create(container, options)` ```js const grid = AVGrid.create("#host", { rows }); // CSS selector const grid = AVGrid.create(document.body, { rows }); // or an element ``` `container` is an element or a selector. `options.rows` is the only required option. Returns an `AVGrid` instance. Throws [`AVGridError`](#errors-and-warnings) on input the grid cannot use. > **Writing React?** Read [`react-api.md`](react-api.md) instead — the same grid as a > component, with everything on this page available as props. This page stays framework-free. ### `AVGrid.createFilterBar(container, { grid, className?, name? })` Mount a filter bar somewhere other than directly above the grid. See [The filter bar](#the-filter-bar). ### `AVGrid.version` The library version string. Also exported as `version` from the package root, and reported by `getState().version`. ### Instance properties | Property | What it is | |---|---| | `grid.element` | The grid's root element. Style it, or read `data-*` off its cells in a test. | | `grid.model` | The model hub — every sub-model hangs off it. Public so a host can reach past the façade. | | `grid.render` | The virtualization engine instance, including `grid.render.stats`. | --- ## Options Every option is optional except `rows`. Everything here can also be passed to `grid.setOptions({ … })` at any time. ### Data | Option | Type | Default | Notes | |---|---|---|---| | `rows` | `readonly R[]` | — | **Required.** The rows to display. | | `columns` | `Column[]` | inferred | See [Columns](#columns). | | `getRowKey` | `(row: R) => string` | inferred | A stable row identity. Selection, focus and editing all address rows by it, so it survives sorting and filtering. | ```js AVGrid.create(el, { rows, getRowKey: (row) => row.orderId, }); ``` ### Row selection | Option | Type | Default | Notes | |---|---|---|---| | `selectColumn` | `boolean` | `false` | The checkbox column, pinned left. It is the grid's own: it does not appear in `getColumns()` and does not survive into `setColumns()`. | | `selected` | `readonly string[]` | `[]` | Initially selected rows, **by row key**. | | `onSelectionChange` | `(keys: string[]) => void` | — | The checkbox selection changed. Call `getSelectedRows()` for the objects — that is O(rows) and so is not done for you. | | `focus` | `CellFocus \| null` | `null` | The focused cell and the range around it. Keys alone are enough; `selection` may be omitted for a single cell. The same value `onFocusChange` reports, and safe to hand straight back — see [Focus and range selection](#focus-and-range-selection). | ```js AVGrid.create(el, { rows, selectColumn: true, selected: ["3", "7"], onSelectionChange: (keys) => console.log(keys.length, "selected"), }); ``` Row selection (checkboxes) and cell range selection are two different things a grid can have at once — `getSelected()` for the first, `getSelection()` for the second. ### Editing | Option | Type | Default | Notes | |---|---|---|---| | `editable` | `boolean` | `false` | Let the user edit cells in place. | | `onEdit` | `(e: CellEditEvent) => void \| boolean` | — | Fires once per committed cell, **before** the write. Return `false` to reject it. | | `onInvalidEdit` | `(e: InvalidEditEvent) => void` | — | The value could not be coerced to the column's type. Default is to do nothing — a library should not beep uninvited. | ```js AVGrid.create(el, { rows, editable: true, onEdit: (e) => save(e.rowKey, e.columnKey, e.value), }); ``` **The grid writes the value into the row object** (`row[column.key] = value`) and then calls `onEdit`. Return `false` to keep the write from happening and own the update yourself. How an editor opens: | Gesture | What happens | |---|---| | Click a cell that already has the focus | Opens, **caret where the click landed**, nothing selected. A cold cell therefore takes two clicks and a focused one takes a single click. | | Double-click | Opens the same way. | | `Enter` / `F2` / `grid.startEdit()` | Opens with the **whole value selected**, so the next keystroke replaces it. | | Type a printable character | Opens with that character as the value, caret after it. | | `Escape` | Cancels. | | `Enter`, `Tab`, `ArrowUp`, `ArrowDown`, blur | Commits. `Tab` and the vertical arrows then move the focus; the horizontal arrows stay in the text, moving the caret. | Boolean columns have no text editor: `Space`, `Enter` or a double-click toggles them across the whole selection, and an editable boolean cell carries a checkbox that toggles on the first click (only the box toggles — the cell around it just selects). `Delete` clears every editable cell in the selection. A column with `options` opens the library's own themed dropdown rather than a native ``; return { element: el, getValue: () => ({ from: el.querySelector("[data-from]").value, to: el.querySelector("[data-to]").value, }), }; }, label: (value) => `${value.from || "…"} – ${value.to || "…"}`, // the chip's text match: (value, row, column) => { // keep the row? const v = row[column.key]; return (!value.from || v >= value.from) && (!value.to || v <= value.to); }, }; AVGrid.create(el, { rows, columns: [{ key: "created", filter: dateRangeFilter }], filterBar: true, }); ``` **No registration call.** The definition is an object on the column; reuse across columns is a shared `const`. There is no order of calls to get right, which is the same rule as everything else in this API. ```ts interface FilterDefinition { name: string; // stored as the filter's `type` create: (ctx: FilterBodyContext) => FilterBody; label: (value: any, column: Column) => string; match?: (value: any, row: R, column: Column) => boolean; // ^ optional in the type only for `externalFilter: true`, where the row test never runs. // Required — enforced at create(), naming the column — everywhere else. serialize?: (value: any) => any; // only if the value is not JSON deserialize?: (stored: any) => any; } interface FilterBody { element: HTMLElement; getValue: () => any; // read when Apply is pressed; nullish removes the filter focus?: () => void; // defaults to the first control in `element` destroy?: () => void; // called every way the popover closes } interface FilterBodyContext { column: Column; value: any; // the applied value, or undefined the first time filter: Filter; // the whole applied filter, normalized apply: (value: any) => void; // apply and close — for a body with its own Enter close: () => void; // close without applying } ``` What the grid does, so the snippet above is the whole filter: - **The funnel** on that column opens your body instead of the checklist. `filterType: null` still takes the funnel off; `disableFiltering` still takes them all off. - **Apply and Clear.** Apply reads `getValue()`; Clear applies nothing. A **nullish value removes the filter**, so Apply with an empty body is Clear — the same rule as an empty checklist. - **The chip**, reading `label(value, column)`, reopening your body anchored to itself, removing the filter from its ✕. - **Persistence**, if `persistFilters` is on, and the cascade: an `"options"` filter on another column offers only the values your filter leaves reachable. - **Teardown.** `destroy()` is called when the popover closes, however it closes. ⚠ **`match` runs once per row, on every filter pass.** Do the parsing in `getValue` and put the result in the value: ```js getValue: () => ({ from: fromInput.value, // for the chip and for storage fromMs: Date.parse(fromInput.value), // parsed once, here toMs: Date.parse(toInput.value), }), match: (v, row) => row.created >= v.fromMs && row.created <= v.toMs, // two comparisons ``` The value is data you control, which is why there is no second `prepare`-style hook. Measured on 100,000 rows: a `match` written this way filters them in **1.7 ms** — *less* than the built-in options test on the same rows, which resolves `formatValue` and `optionMatches` per row — and the identical filter re-parsing its bounds inside `match` takes **90 ms**, fifty-six times as long. That is the whole cost of getting this wrong. See [`tasks/benchmark-results.md`](../tasks/benchmark-results.md). **Persistence is JSON.** The value is stored as it stands, with ISO date strings revived into `Date`. Add `serialize` / `deserialize` only when the value cannot survive that — a `RegExp`, a `Map`, or an `Infinity` you would rather rebuild than trust. A stored filter whose column no longer has that definition is **dropped with a warning** rather than silently filtering nothing. A definition missing `name`, `create`, `label` or `match` throws from `create()`, naming the column and the field. ### `onGetOptions` — supply the options yourself ```ts type GetFilterOptions = ( columns: Column[], filters: Filter[], // the *other* applied filters, never this column's own columnKey: string, search?: string, // what the user has typed into the popover's search box ) => DisplayOption[] | Promise; ``` ```js onGetOptions: async (columns, filters, columnKey, search) => (await fetch(`/values/${columnKey}?q=${search ?? ""}`)).json(), ``` The list shows a loading row until the promise resolves, and a response overtaken by a newer one is discarded. Ignore `search` and the list narrows the returned options itself. ```ts interface DisplayOption { value: T; label: string; italic?: boolean } ``` ### Host-owned filtering and sorting For rows that arrive already filtered and sorted — usually by a server, over a dataset too large to load. The grid keeps its whole filter and sort UI; the host owns the round trip: ```js AVGrid.create(el, { rows, // one server-filtered, server-sorted page externalFilter: true, // don't test rows against `filters` externalSort: true, // don't reorder rows filterBar: true, onFiltersChange: (filters) => reload({ filters }), // the round trip is the host's onSortChange: (sort) => reload({ sort }), onGetOptions: async (columns, filters, columnKey, search) => (await fetch(`/values/${columnKey}?q=${search ?? ""}`)).json(), }); ``` **What each flag changes — deliberately almost nothing.** | | `externalFilter: true` | `externalSort: true` | |---|---|---| | **Skipped** | the `filters` test in the row pass | the reorder in the row pass | | **Unchanged** | funnels, popovers, filter types, chips, the bar, persistence, `onFiltersChange`, `isFiltered`, `applyFilter` / `setFilters` / `clearFilters` | header arrows, position numbers, `aria-sort`, the click and Ctrl/Cmd+click gestures, `multiSort`, `onSortChange`, `getSort` / `setSort` | | **Unused by the grid** | `FilterDefinition.match` | `Column.sortValue`, `Column.rowCompare` | The two are independent — a grid may sort a loaded page locally while filtering server-side, and the other way around. Both are ordinary options: toggle either with `setOptions()` and the row pipeline re-runs; both reach the React wrapper as plain props. **Pass `onGetOptions` with `externalFilter`.** The built-in checklist offers the distinct values of the *loaded* rows — and a server-filtered page has every filter, **including the column's own**, already baked in. The cascade's usual rule (a column's own filter is excluded, so unticked values stay offered) cannot work when the exclusion already happened on the server: the checklist can only offer values that currently pass, so it can narrow a filter but never re-widen one. Only the host has seen the full value set. Not enforced — a boolean column's two page-derived values are still correct — but it is the confusing case this option exists to remove. **`FilterDefinition.match` may be omitted** while `externalFilter` is on — the row test never runs, and `match: () => true` on every column would be noise. It stays required otherwise, checked at `create()` with an error naming the column; turning `externalFilter` *off* with `setOptions()` re-checks, so a definition accepted without one can never be left silently keeping every row. **There is no `externalSearch`, on purpose.** `searchString` is a local row search by definition and keeps filtering the loaded page. A host searching server-side already has the right tool: pass its words to [`highlightString`](#search-highlighting), which marks matches and filters nothing. ### Persistence ```js AVGrid.create(el, { rows, persistFilters: { name: "orders" } }); ``` Stored under `Filters-${name}`. **Passing the object is the consent** — nothing is written otherwise, and there is no default-on form. Stored filters take precedence over the `filters` option, which stays the default for a first visit. `Date` values are revived without losing the labels around them. ```ts interface PersistFiltersOptions { name: string; storage?: FilterStorage; // defaults to localStorage } interface FilterStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } ``` `localStorage` and `sessionStorage` both satisfy `FilterStorage` structurally, so `persistFilters: { name: "orders", storage: sessionStorage }` needs nothing implemented. Exported for hosts that want to manage the store directly: `filtersStorageKey`, `hasStoredFilters`, `readStoredFilters`, `writeStoredFilters`, `reviveFilters`, `FILTERS_CONFIG_VERSION`. --- ## The filter bar Applied filters shown as removable chips. A chip reads `Status: open,pending (+3)`; clicking its body reopens the popover **anchored to the chip**, its ✕ removes that filter, and the ✕ at the right end removes them all. **The bar takes no space at all until something is filtered.** Two ways to mount one, both making the same object: ```js // Directly above the grid const grid = AVGrid.create("#host", { rows, filterBar: true }); grid.getFilterBar(); // Or anywhere else — a toolbar, a panel of its own const bar = AVGrid.createFilterBar("#toolbar", { grid }); bar.element; // the root; append it wherever bar.refresh(); // redraw from the grid's current filters bar.destroy(); // yours to destroy — grid.destroy() does not own this one // Chips only — for a toolbar that has its own clear control AVGrid.createFilterBar("#toolbar", { grid, clearButton: false }); ``` A grid can have any number of bars watching it, mounted either way; they all show the same filters and any of them can edit them. **Your word on a chip — `filterLabel`.** The built-in chip text is `contains smith` for a text filter, `open,pending (+3)` for a checklist, and a custom definition's own `label`. To say it differently — a localized *is empty*, a domain word for a value — give the grid one callback: ```js AVGrid.create(el, { rows, columns, filterBar: true, filterLabel: (filter, column, defaultText) => { if (filter.type === "text" && filter.value?.op === "blank") return "no address on file"; return undefined; // undefined → the built-in text }, }); ``` It runs for every applied filter, whatever its type, *after* a definition's `label` (which it receives as `defaultText`), so one callback covers the whole bar. `undefined` keeps the default, so you override one case and inherit the rest. The result is not truncated — you chose it — and it is the chip's tooltip too. `describeFilter()` honours it, so chips you draw yourself agree with the bar. A throw is caught, warned once naming the column, and the default text shows. It names the **bar** chip only; the operator chips inside the text popover keep their labels. `filterBar` is read at `create()`, because the grid wraps itself in a flex column to make room and that is not something to do to a page later. `setOptions({ filterBar: false })` still takes the bar away, and `true` puts it back on a grid that was *created* with one — on a grid that was not, it warns and points you at `createFilterBar()`. ### A bar of your own When the built-in bar's markup is not enough — chips interleaved with your own controls, your own chip design — skip it and render chips yourself. Everything a chip does is a public call, and `describeFilter()` hands you the exact strings the built-in chip would show, so the two can never disagree: ```js const grid = AVGrid.create("#host", { rows, onFiltersChange: renderChips }); function renderChips(filters) { toolbar.replaceChildren( ...filters.map((f) => { const { name, values, title } = grid.describeFilter(f); const chip = document.createElement("span"); chip.title = title; // the full value list chip.textContent = `${name}: ${values}`; // e.g. `Status: open,pending (+3)` chip.onclick = () => grid.showFilterPopover(f.columnKey, { anchor: chip }); // and a ✕ calling grid.removeFilter(f.columnKey) return chip; }), ); } myClearButton.onclick = () => grid.clearFilters(); ``` | Piece of a chip | The call | |---|---| | Its text and tooltip | `grid.describeFilter(filter)` → `{ name, values, title }` — with your `filterLabel` applied, if you gave one | | Clicking its body | `grid.showFilterPopover(filter.columnKey, { anchor: chipElement })` | | Its ✕ | `grid.removeFilter(filter.columnKey)` | | The remove-all ✕ | `grid.clearFilters()` | | Knowing when to redraw | `onFiltersChange` — fired for every source: the API, a funnel, any bar | --- ## The context menu Right-click gives Copy, Copy as… (With Headers / JSON / Formatted HTML Table), Paste, and Insert / Add / Delete for both rows and columns — each label counting what the selection actually covers, and a header getting the two column items instead. Three hooks, in order of how much they take over: ```js // 1. Add items above the built-in ones getContextMenuItems: (e) => [ { label: `Open ${e.rowKey}`, onClick: () => open(e.row) }, ], // 2. Draw the menu yourself, with the items the grid would have shown onGridContextMenu: (e, items) => myMenu.show(e.x, e.y, items), // 3. Hand the gesture back to the browser disableContextMenu: true, ``` `onGridContextMenu` receives the point in viewport coordinates — exactly what `showMenu({ anchor: { x, y } })` wants — and the items **already filtered to the ones that apply**. Each item's `onClick` does the work; nothing else is needed to make them behave. It suppresses the grid's menu *and* the browser's; `disableContextMenu` is how you get the platform menu back. The grid never shows its menu over an open cell editor either way — a user editing text wants Cut / Paste / Spelling, which only the platform can offer. ```ts interface GridContextMenuEvent { x: number; y: number; target: "cell" | "header" | "grid"; column?: Column; row?: R; rowKey?: string; rowIndex?: number; // display indices, counting data rows colIndex?: number; readonly selection?: GridSelection; // a getter — see below selectedCount: SelectedCount; // rows, columns, and the top-left corner event: MouseEvent; } ``` **`e.selection` is a getter**, computed at most once. Reading it on a grid with all 100,000 rows selected copies 100,000 row references, and most menus only need `selectedCount`. Nothing built-in reads it, which is why opening the menu over 100,000 rows costs 2.3 ms with every row selected against 2.9 ms with one. ```ts interface MenuItem { label: string; onClick?: () => void; icon?: Node | string; // a node, or a string inserted as markup disabled?: boolean; // shown but not pickable invisible?: boolean; // left out entirely startGroup?: boolean; // separator above hotKey?: string; // right-aligned hint, e.g. "(Ctrl+C)". Binds nothing. selected?: boolean; // checked, and highlighted when the menu opens minor?: boolean; // dimmed id?: string; // stable identity; the built-in items all carry one items?: MenuItem[]; // submenu } ``` `MenuItem` is Persephone's own shape, so a host that already builds these can hand the same array to either menu. ### The built-in item ids Every item the grid builds carries a stable `id`. Match on it rather than on the label: the labels count and pluralise what the selection covers (`Insert 3 rows`), and they are the part a host may want to translate. | Item | `id` | |---|---| | Insert column *(header right-click)* | `avg-insert-column` | | Delete column *(header right-click)* | `avg-delete-column` | | Copy | `avg-copy` | | Copy as… | `avg-copy-as` | | ↳ With Headers | `avg-copy-as-headers` | | ↳ JSON | `avg-copy-as-json` | | ↳ Formatted (HTML Table) | `avg-copy-as-html` | | Paste | `avg-paste` | | Insert *n* rows | `avg-insert-rows` | | Add *n* rows | `avg-add-rows` | | Delete *n* rows | `avg-delete-rows` | | Insert *n* columns | `avg-insert-columns` | | Add *n* columns | `avg-add-columns` | | Delete *n* columns | `avg-delete-columns` | **These are a stable contract** — they will not be renamed without a major version. The motivating case is a host menu that draws icons its own way, since `icon` here is markup or a node and a menu taking icon *components* cannot use either: ```js onGridContextMenu: (e, items) => { for (const item of items) { if (item.id === "avg-copy") item.icon = myIcons.copy; } myMenu.show(e.x, e.y, items); } ``` The `avg-` prefix is deliberate. Host items from `getContextMenuItems` are prepended into the same array and `id` is what the menu uses for keyboard navigation, so a collision between a host id and a built-in one would be a real bug — and `item.id?.startsWith("avg-")` is how you tell the library's own items from yours. --- ## Keyboard reference ### Navigation | Key | Does | |---|---| | `←` `→` `↑` `↓` | Move one cell | | `Ctrl+↑` / `Ctrl+↓` | One viewport | | `Ctrl+←` / `Ctrl+→` | First / last column | | `PageUp` / `PageDown` | One viewport | | `Home` / `End` | First / last row | | `Ctrl+Home` / `Ctrl+End` | First / last cell | | `Tab` / `Shift+Tab` | Next / previous cell, wrapping across rows | | `Shift+` any of the above (not `Tab`) | Extend the range selection | | `Ctrl+A` | Select every cell | | `Alt+↓` | Open the focused column's filter popover, anchored at its header — the Excel gesture | | `→` / `←` on a tree cell | With `onTreeToggle`: expand a collapsed folder / collapse an expanded one. Otherwise, and on a leaf, move one cell as usual — see [Tree column](#tree-column--treecolumn) | | Menu key (`≣`) | Open the context menu at the focused cell — the browser fires `contextmenu` on the focused element, and the grid resolves that to the focus | `↓` on the last row, `Tab` off the last cell, and `Ctrl+→` off the last column each grow the grid by one, when the matching `can*` option is on. **Sorting has no keyboard binding.** A header click is the only built-in gesture — with `multiSort: true`, Ctrl+click (Cmd+click on macOS) appends a sort level, but that is a pointer gesture too. A keyboard-first consumer sorts through its own UI and `grid.setSort()`. Named as a known gap in [capabilities.md](capabilities.md#accessibility--what-conformance-we-claim), not hidden. ### Editing | Key | Does | |---|---| | `Enter` / `F2` | Open the editor, value selected | | any printable key | Open the editor with that character | | `Space` | Toggle a boolean column — or open its `editor`, if it has one | | `Enter` (boolean) | Toggle the selection to the focused cell's opposite | | `Escape` | Cancel | | `Enter` / `Tab` / `↑` / `↓` (in the editor) | Commit, then move | | `←` / `→` / `Home` / `End` (in the editor) | Move the caret; the grid does not act | | `Escape` / `Tab` (in a custom `editor`) | Cancel / commit-and-move, bound by the grid unless the editor calls `preventDefault()` | | `Delete` | Clear every editable cell in the selection | ### Clipboard | Key | Does | |---|---| | `Ctrl+C` | Copy the selection as TSV | | `Ctrl+Shift+C` | Copy with a header row | | `Ctrl+V` | Paste into the selection | | `Ctrl+X` | Copy, then clear (editable grids) | ### Structure | Key | Does | |---|---| | `Ctrl+Insert` | Insert blank rows above the selection, as many as it covers | | `Ctrl+Shift+Insert` | Insert blank columns before the selection | | `Ctrl+Delete` | Delete the rows the selection covers | | `Ctrl+Shift+Delete` | Delete the columns the selection covers | `Numpad0` and `NumpadDecimal` stand in for `Insert` and `Delete` when NumLock is off, as they do everywhere else. --- ## Theming **Setting a custom property re-tints the grid with no JavaScript and no repaint** — the browser does it, and nothing in the library reads a colour. A theme change costs **zero** paints. ### Which property, and where — read this first Four elements define the whole `--avg-*` block **on themselves**, each from its `--p-*` counterpart with a neutral fallback: the grid root (`[data-type="render-grid"].avg-grid`), a popover (`.avg-popover`), a virtual list (`.avg-list`) and the filter bar (`.avg-filter-bar`). They have to — a popover is mounted on `document.body` and a filter bar can be mounted anywhere, so neither can rely on inheriting from a grid. The consequence is the one rule worth knowing: | Where you set it | Effect | |---|---| | `--p-*` on any ancestor (`:root`, `body`, a wrapper) | **Reaches everything** — the grid, its popovers, its dropdown, its filter bar. This is how you theme a page. | | `--avg-*` on any ancestor | **Does nothing.** The element's own definition shadows it. | | `--avg-*` on the element itself | **Wins.** This is how you make one grid differ. | ```css /* Theme the page. Every --avg-* token falls back to one of these. */ :root { --p-accent: #d83b01; --p-bg: #ffffff; --p-text: #202020; --p-bg-dark: #f3f2f1; /* the header band and the filter bar */ --p-border-light: #e5e5e5; /* the cell lines */ } ``` ```js // Make one grid deviate. On the grid root, not on the host — the host is an ancestor. grid.element.style.setProperty("--avg-header-bg", "#1d7a4f"); ``` ```css /* The same from a stylesheet needs to beat the library's own selector, which is [data-type="render-grid"].avg-grid — two classes' worth of specificity. */ [data-type="render-grid"].avg-grid { --avg-header-bg: #1d7a4f; } ``` Every token falls back to its Persephone `--p-*` equivalent where one exists, then to a neutral light default — so the grid looks deliberate on a bare HTML page and matches the app on a board. | Token | Falls back to | Then | |---|---|---| | `--avg-font-family` | `--p-font-family` | a system stack | | `--avg-font-size` | `--p-font-base` | `13px` | | `--avg-text` | `--p-text` | `#202020` | | `--avg-text-muted` | `--p-text-muted` | `#767676` | | `--avg-bg` | `--p-bg` | `#ffffff` | | `--avg-accent` | `--p-accent` | `#0078d4` | | `--avg-border-color` | — | 22% of `--avg-text`. Popover edges, button borders, menu separators, the resize grip. | | `--avg-grid-line` | `--p-border-light` | 11% of `--avg-text`. The cell lines — texture between values, deliberately weaker than `--avg-border-color`. | | `--avg-header-bg` | `--p-bg-dark` | 7% of `--avg-text` over `--avg-bg`. The header band and the filter bar: chrome, so *darker* than the grid in a dark theme. | | `--avg-header-text` | — | `--avg-text` | | `--avg-cell-bg` / `--avg-cell-text` | — | `--avg-bg` / `--avg-text` | | `--avg-hover-bg` | — | 6% of `--avg-text` | | `--avg-selection-bg` | — | 18% of `--avg-accent` | | `--avg-selection-border` | — | `--avg-accent` | | `--avg-selection-border-blurred` | — | `--avg-text-muted`. The selection outline while the grid does not have focus. | | `--avg-search-match` | — | `--avg-accent`. A matched search word. Its own token because it is the one place the accent lands on *text*. | | `--avg-menu-selection-bg` | `--p-selection-bg` | `--avg-accent`. A menu row is *picked*, so it takes the full selection colour, not the tint a checklist wants. | | `--avg-menu-selection-text` | `--p-selection-text` | `#ffffff` | | `--avg-cell-padding-x` | — | `4px` | A token whose fallback column says "—" has no `--p-*` counterpart, so it can only be set on the element itself — which also means it is derived from the others and usually needs no setting at all. ### On a Persephone board Nothing to wire up. The `--p-*` contract is read directly, so a board's own theme — including a live theme switch — reaches the grid with no code: ```js const grid = AVGrid.create("#host", { rows }); // persephone.onThemeChange fires; the grid re-tints itself. No repaint, no callback needed. ``` --- ## DOM contract Useful for host CSS and for driving the grid from a test. **Class names and `data-*` attributes are part of the public surface; the DOM structure is not** — cells are pooled and absolutely positioned, and their nesting can change. | Attribute | On | |---|---| | `data-type="render-grid"` | The root | | `data-name` | The root, from the `name` option | | `data-type="header-cell"` | A header cell | | `data-type="data-cell"` | A data cell | | `data-type="footer-cell"` | A footer-row cell (`footerRows`), carrying `avg-footer-cell`. It has **no `data-row`** — a footer cell stands for no data coordinate, which is what keeps every interaction away from it — and `data-footer-row` is its index into `footerRows`. | | `data-row` | Row index. A header cell carries `0`; a data cell carries its **data** row index, so data row 0 also reads `0` — `data-type` is what tells them apart. | | `data-col` | Column index — into the **visible** columns, i.e. `columns` with `hidden` ones dropped. `getColumns()` returns the full array, so the two disagree the moment a column is hidden: map a cell back to its column by `data-column-key`, not by indexing `getColumns()` with this. It is **not renumbered inside a pinned band**: a pinned cell's index is its real one. | | `data-column-key` | The column's `key` | | `data-sort="asc" \| "desc"` | A sorted header cell. `aria-sort="ascending" \| "descending"` rides on the **primary** sorted column only; with `multiSort` and 2+ sorted columns, each sorted header holds its position number in an `avg-sort-pos` span | | `data-type="group-row"` / `data-type="group-cell"` | The column-group band and its cells (`Column.group`) — overlay divs above the header row, classed `avg-group-cell`, each carrying `data-group` with its label. `aria-hidden`, not pooled, and **no** `data-row` / `data-col`: a group cell stands for a span, not a coordinate | | `role` / `aria-*` | The root is `role="grid"` with live `aria-rowcount` / `aria-colcount` and `aria-multiselectable`; header cells `role="columnheader"` + `aria-colindex`; data and footer cells `role="gridcell"` + 1-based `aria-rowindex` / `aria-colindex` (the header is row 1). There are deliberately **no row elements**, so this is grid semantics with sort state exposed, not a fully conformant ARIA grid — see [capabilities.md](capabilities.md#accessibility--what-conformance-we-claim). | | `data-resizable` | A header cell | | `data-pinned="left" \| "right"` | A pinned column's header cell. On `"right"`, the resize grip moves to the cell's left edge. | | `data-type="filter-button"` | The funnel inside a header cell | | `data-type="cell-editor"` | The open editor | | `data-part="tree-indent"` / `data-part="tree-chevron"` | The tree gutter's guides and chevron slot inside a `treeColumn` cell (classed `avg-tree-cell`); the content follows in `avg-tree-content`. The first guide carries `data-first`; the slot is `avg-tree-chevron` (`data-expanded` when open, `data-inert` when there is no gesture) or `avg-tree-stub`. `aria-expanded` rides on the gridcell. See [Tree column](#tree-column--treecolumn). | | `data-avg-action="add-row" \| "add-column"` | The two `+` buttons | | `data-avg-slot="content-end"` | The host's `extraElement`, which also carries `avg-extra` | | `data-cell-borders="off"` | The root, with `cellBorders: false` | | `data-search-highlight="background" \| "both"` | The root. Absent for the default, colour-only shape | | `data-avg-pooled` | A cell evicted while `keepCellsAttached` is on: hidden, kept in the document, and standing for no coordinate. **It has no `data-row` / `data-col`** — they are removed on eviction and written back on re-admission, so a hidden cell can never shadow the live one at the same coordinate. Only ever present with that option. | Cell state classes, which the four class hooks add to rather than replace: `avg-focused`, `avg-editing`, `avg-in-selection` (plus `-top` / `-right` / `-bottom` / `-left` for the edges), `avg-row-hovered`, `avg-row-selected`, `avg-align-center`, `avg-align-right`. A text cell's content lives in one `avg-cell-text` span — **both** the plain and the matched shape, so the two lay out identically. It carries the cell's truncation: `text-overflow` needs a block container and the cell itself is `inline-flex`, where a bare text node becomes an anonymous flex item that cannot be styled, so the ellipsis lives on this wrapper. It is also what keeps the spaces either side of a mark, since flex discards whitespace *between* items. A boolean cell and a cell filled by a column's `render` hook have **no** wrapper — so **a `render` column that returns text can emit `` itself to get the same ellipsis**, and one that draws a graphic simply does not. Inside the wrapper, `avg-search-match` wraps each matched search word; a cell with no match has no mark and holds a bare text node. On a header cell's funnel: `avg-column-filtered` when that column is filtered, `avg-filter-open` while its popover is up. Root-level classes worth knowing: `avg-grid` (the root), `avg-grid-wrap` (the flex column a `filterBar` grid lives in), `avg-header-cell`, `avg-data-cell`, `avg-filter-bar`, `avg-popover`, `avg-menu`, `avg-list`. The shell inside the root is classed too, for the styling the `--avg-*` tokens do not cover: | Class | The element | |---|---| | `avg-viewport` | The scrolling element. **The place for scrollbar styling** — `.avg-grid .avg-viewport::-webkit-scrollbar { … }` — and for `scrollbar-width` / `scrollbar-color`. | | `avg-cells-area` | The sized canvas the pooled cells position themselves in. | | `avg-sticky-top` | The band the header row lives in — a background or `box-shadow` here styles the whole header, not cell by cell. | | `avg-sticky-bottom` / `-left` / `-right`, `avg-sticky-top-left` / `-top-right` / `-bottom-left` / `-bottom-right` | The other sticky bands and corners. Empty unless something is frozen on that edge: the checkbox column and `pinned: "left"` cells sit in `avg-sticky-left`, `pinned: "right"` cells in `avg-sticky-right` — and pinned columns' **header** cells in the matching top corner. | These are containers, not cells: put backgrounds, borders and scrollbar styling on them, but leave `position`, `overflow` and `transform` alone — the virtualization owns those. Inside a filter popover: `avg-filter-content` (either body), `avg-filter-buttons` (the Apply / Clear row), and for a `column.filter`, `avg-custom-filter-content` on the panel with `avg-custom-filter-body` around the element your `create()` returned. On a `"text"` filter's body, `avg-text-filter-text-unused` is present while a text-free operator (*is empty* / *is not empty*) is pressed — the input is then read-only and empty, and this is the hook to dress that state. **If you write your own cell renderer returning an element, the stylesheet must position it absolutely.** The engine writes `top` and `left`; nothing writes `position`. A cell that lays out in flow looks correct at the top of a list and shows an empty band everywhere below. ### Rows pinned to the bottom — `footerRows` ```js AVGrid.create(el, { rows, columns, footerRows: [{ label: "Total", spend: 4_812_500, members: 71_475 }], footerRowClass: () => "grand-total", }); grid.setOptions({ footerRows: undefined }); // and the band goes away ``` Footer rows **are rows**: the same shape as `rows`, rendered through the same columns — `formatValue`, `displayFormat`, `align`, `render` and `cellClass` all apply unchanged, so your number formatting is written once. They live in the `avg-sticky-bottom` band, pinned below the scrolling rows at every scroll position, each cell carrying `avg-footer-cell` plus whatever `footerRowClass` returns. And they are **only rendered** — invisible to everything that treats a row as data: - **Sorting, filtering, `searchString`** — a footer row never moves, never disappears, and is not a search match. - **Row selection** — no checkbox under `selectColumn`, select-all skips it, `getSelected()` never names it. - **`getVisibleRows()` / `onVisibleRowsChange` / `getState().rowCount`** — those keep meaning *data* rows. - **Editing** — readonly regardless of `editable`; `startEdit` past the data rows refuses. - **Focus, range selection and copy** — a footer cell is not a coordinate: it carries no `data-row`, a drag downwards stops at the last data row, and a `selectRange` reaching past the data is refused. - **Add/delete-row affordances** — the footer is not "the last row" for ArrowDown off the end, Ctrl+Insert, or a tall paste. - **`getRowKey`** — never called for one. Footer rows are keyed `avg-footer-` internally, so a key reading a field your totals row does not have cannot throw. `rowHeight` applies to footer rows and the grid's height accounts for them (`headerHeight` sizes the header only — a footer row is as tall as a data row). With a footer the trailing slack defaults to `0` — the band itself is what the last data row scrolls clear of — but an explicit `whiteSpaceY` still buys room *between* the last data row and the band, which is where an `extraElement` sits: content, slack (with the `extraElement` in it), band. ### An element after the last row `extraElement` puts one host element into the scrolling content, after the last row: a "Load more" footer, an empty-state line, a total. ```js const footer = document.createElement("div"); footer.textContent = "Load more"; const grid = AVGrid.create(el, { rows, extraElement: footer, whiteSpaceY: 24 }); grid.setOptions({ extraElement: null }); // and it goes away ``` The grid **parents it and nothing else** — never inspected, cleared, restyled beyond adding `avg-extra`, or destroyed. Listeners you bind to it survive a repaint and a scroll long enough to evict every cell around it, because it is an overlay rather than a pooled cell. `destroy()` takes it out of the DOM and leaves it intact, so it can be mounted somewhere else. **Unlike a cell renderer, this one the library positions** — a full-width band at the bottom of the content: ```css .avg-grid .avg-extra { position: absolute; left: 0; right: 0; bottom: 0; } ``` There the engine writes `top` and `left` and the host only has to add `position`; here nothing writes anything, so an unpositioned element would lay out in flow among absolutely positioned cells and land at the top-left *behind* them — invisible and still hoverable. One more class overrides it, and no colour, size or padding is set, because only the host knows what the grid's background is: ```css .avg-grid .avg-extra.my-chip { left: 4px; right: auto; } ``` Two things to know rather than work around: - **It lives in the trailing slack, which is 20 px.** Taller than that and it overlaps the last row at full scroll. Raise [`whiteSpaceY`](#layout) to its height to reserve the room, or give it an opaque background. - **It shares that strip with the add-row button**, which sits at `bottom: 1px; left: 4px`. A full-width default band will sit over it; a grid that wants both positions its own element clear. --- ## Driving the grid from an agent If you are testing av-grid through Persephone's MCP browser tools, read this before you conclude anything from a click. It has already cost one port a day: three of the four bugs it reported were the harness, not the grid. ### The one thing to know **`browser_click` and `browser_press_key` dispatch synthetic events.** A `browser_click` fires a bare `click` — no `pointerdown`, no `mousedown`. A `browser_press_key` fires a `keydown` with `isTrusted: false`. Two consequences, and both look exactly like a grid bug: - **The grid resolves pointer gestures on `pointerdown`** — focus, cell focus, range drags, the boolean checkbox — because a repaint between the press and the click replaces the element the press landed on (see the third invariant in `CLAUDE.md`). A lone synthetic `click` reaches none of that, so the grid looks like it ignored the click and left `document.activeElement` on ``. - **A synthetic key cannot drive the clipboard.** `Ctrl+C` works through the browser's own `copy` event, which only trusted input produces — on every browser, in every frame. `keydown` arrives, no `copy` follows, nothing is written. This is not evidence of anything about your environment. An MCP-driven page usually does not have OS focus either, so `document.hasFocus()` is `false` and some browsers decline clipboard work on that basis alone. Log both `e.isTrusted` and `document.hasFocus()` before you believe a clipboard result. ### Prefer the API Almost everything worth asserting is reachable without synthesising an event at all, and this is the path to reach for first: ```js grid.focusCell(10, 2); // move the cell focus grid.selectRange(10, 2, 40, 5); // a range, as after a drag grid.startEdit(10, 2); // open the editor await grid.copySelection("copy"); // the clipboard, through navigator.clipboard grid.setSearchString("ada"); grid.setFilters([...]); grid.setSort({ key: "score", direction: "desc" }); grid.getState(); // columns, viewport, focus, counts — JSON-serializable grid.getFocus(); grid.getSelection(); grid.getSelectionText(); ``` `getState().viewport.width === 0` is the answer to a grid that rendered blank, and worth checking first whenever a selector finds no cells. ### When you do need a real gesture Synthesise the whole pointer sequence in `browser_evaluate`. `clientX` / `clientY` must be real screen coordinates — the grid hit-tests the point for sticky bands and for drags: ```js const cell = document.querySelector('[data-type="data-cell"][data-row="3"][data-col="2"]'); const r = cell.getBoundingClientRect(); const o = { bubbles: true, cancelable: true, clientX: r.left + 10, clientY: r.top + 8, button: 0, buttons: 1, pointerId: 1, pointerType: "mouse", isPrimary: true }; cell.dispatchEvent(new PointerEvent("pointerdown", o)); // focus + cell focus happen here window.dispatchEvent(new PointerEvent("pointerup", { ...o, buttons: 0 })); // window, not the cell cell.dispatchEvent(new MouseEvent("click", o)); // only for onCellClick ``` - **`pointerup` goes on `window`**, because that is where a range drag listens — a drag has to keep being heard after it leaves the grid. - **A range drag** is `pointerdown` on the first cell, then `pointermove` on `window` with the coordinates of each later cell, then `pointerup`. - **Opening an editor takes two presses** on a cold cell, one on a cell that already has the focus. That is the real gesture; `grid.startEdit()` is the shortcut. - **Keyboard navigation works synthetically** — the grid's own `keydown` handler does not care about `isTrusted`. Dispatch on `grid.element`: `grid.element.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }))`. Only the clipboard keys need real input. - **Give it a frame.** Paints are on `requestAnimationFrame`, so `await` a frame or two before reading the DOM back. ### Finding things Cells are addressed by the [DOM contract](#dom-contract) above: `[data-type="data-cell"][data-row="3"][data-col="2"]`, `[data-type="header-cell"]`, `[data-type="cell-editor"]` for the open editor. Only the **visible** window exists — the grid is virtualized, so row 99,000 has no element until you scroll to it. `data-row` on a data cell is the data row index, not the grid row. Screenshots beat accessibility snapshots here: cells are `div`s with no roles, so a snapshot shows a flat list of strings and hides every layout bug. Take a screenshot and look at it. ### Iterating on a board Boards do not auto-reload. After editing the grid's source: `npm run build:board`, then `board_refresh { pageId }`. After editing only the board's own files, `board_refresh` alone. --- ## Primitives Built for the grid's own filter UI, exported because they are useful on their own and because the grid's popovers are themed by the same tokens. ### `Popover` ```js import { Popover } from "av-grid"; const popover = new Popover({ anchor: buttonEl, placement: "bottom-start" }); popover.content.append(myPanel); const result = await popover.show(); // resolves when it closes ``` Anchors to an element or a point, flips instead of clipping, caps its height to the space available and scrolls, dismisses on `Escape` or an outside pointerdown, and resizes by a corner grip. ```ts interface PopoverOptions { anchor: Element | { x: number; y: number }; placement?: PopoverPlacement; // default "bottom-start" offset?: [number, number]; // [cross, main] className?: string; matchAnchorWidth?: boolean; resizable?: boolean; size?: PopoverSize; minWidth?: number; minHeight?: number; onResize?: (size: PopoverSize) => void; ignoreOutside?: string; // a selector; a pointerdown on a match does not close autoFocus?: boolean; // default true document?: Document; } ``` **Never anchor to a data cell.** Cells are pooled, so one scrolled out from under an open popover silently re-anchors it to whatever the element became. Anchor to a header cell, a chip, or a point. ### `VirtualList` A searchable, virtualized checklist on its own `RenderGrid` instance. 100,000 options mount in 4.4 ms with 11 rows in the DOM and scroll at 60 fps. ```js import { VirtualList } from "av-grid"; const list = new VirtualList({ items: values.map((v) => ({ value: v })), multiple: true, onChange: (values) => console.log(values), }); host.append(list.element); ``` ```ts interface VirtualListItem { value: VirtualListValue; label?: string; disabled?: boolean } interface VirtualListOptions { items?: VirtualListItem[]; selected?: readonly VirtualListValue[]; multiple?: boolean; // default true — checkboxes. false gives single-pick search?: boolean; // default true searchPlaceholder?: string; selectAll?: boolean; // defaults to `multiple` selectAllLabel?: string; emptyLabel?: string; rowHeight?: number; headerHeight?: number; // default: rowHeight overscanRow?: number; // default 4 className?: string; onChange?: (values: VirtualListValue[], items: VirtualListItem[]) => void; onActivate?: (item: VirtualListItem) => void; // Enter, or a click in single-pick mode onSearch?: (text: string) => void; // take over filtering } ``` Methods: `setItems`, `getItems`, `getVisibleItems`, `setSelected`, `getSelected`, `getSelectedItems`, `setSearch`, `setActiveIndex`, `getActiveItem`, `scrollToIndex`, `measure`, `focus`, `destroy`. Set `onSearch` and the list stops matching the text itself and just reports what was typed — the seam `onGetOptions(…, search)` needs when the options are narrowed at the source. ### `Menu` / `showMenu` ```js import { showMenu } from "av-grid"; const picked = await showMenu({ anchor: { x: e.clientX, y: e.clientY }, items: [ { label: "Copy", hotKey: "(Ctrl+C)", onClick: () => grid.copySelection() }, { label: "More", items: [{ label: "As JSON", onClick: () => … }] }, ], }); ``` Resolves with the item that was picked, or `undefined`. Prefer `showMenu()` to `new Menu(…)`: a menu that outlives the gesture that opened it has nothing to hold on to, and the promise is the whole contract. Submenus open on hover or click, arrow keys navigate, and a search box appears past `MENU_SEARCH_THRESHOLD` (20) items. `MENU_SUBMENU_DELAY_MS` is 400. ```ts interface MenuOptions { anchor: Element | Point; items: MenuItem[]; placement?: PopoverPlacement; // default "bottom-start" — for a point, down and right offset?: [number, number]; className?: string; document?: Document; } ``` ### The rest `showFilterPopover`, `OptionsFilterContent`, `FilterBar`, `createButton`, `createIconButton`, `createCellInput`, `createCellSelect`, `createDefaultEditor`, `SELECT_COLUMN_KEY`, `createSelectColumn` are all exported for hosts assembling their own filter or editor UI out of the grid's own parts. --- ## Helpers Exported because the grid uses them and a host reproducing the grid's own behaviour elsewhere should not have to reimplement them. | Export | Does | |---|---| | `formatDisplayValue(value, format?)` | What a cell shows, for a `DisplayFormat`. | | `defaultCompare(propertyKey?)` | The comparator the grid sorts by. | | `filterRows(rows, columns, searchString?, filters?, filterColumns?)` | The whole filter pass. Returns the *same array* when nothing filters. `columns` is what the search runs over; `filterColumns` (default: `columns`) is where each filter's column is looked up — the grid passes its visible columns and its full set, so a filter on a `hidden` column still resolves to its column. | | `columnDisplayValue(column, row)` | The plain-text projection used for sort, filter and copy. | | `rowsToCsvText(rows, columns, withHeaders?, tabDelimiter?)` | What `Ctrl+C` produces. | | `defaultValidate(column, row, value)` | The coercion applied when a column has no `validate`. | | `gridBoolean(v)` / `falseString(v)` | The grid's truthiness for boolean columns. | | `highlightText(text, searchString)` | Escape `text` and wrap each search word in ``. For text outside the grid — a summary line, a panel. **Inside a cell use `CellContext.highlight`**, which already holds the grid's search and honours `highlightSearch: false`. | | `searchWords(searchString)` | How the grid splits a search: lowercased, on any whitespace, empties dropped. | | `detectColumnWidth(rows, key, headerName, options?)` | The width one column would be given, in pixels. `rows` are the raw rows, `key` the property read from each, `headerName` the label measured alongside them. `options`: `{ charWidth = 8, padding = 20, minWidth = 60, maxWidth = 300, sampleSize = 100 }`. | | `detectColumnWidths(rows, keys, options?)` | The same for several keys at once, returning `{ [key]: width }`. Each key is its own header label. | | `inferColumns` / `inferGetRowKey` / `inferRowKeyProperty` | What the grid infers from the rows. | | `validateFilters` | The normalization `setFilters()` applies. | | `recordsToCsv` / `csvToRecords` | Dependency-free CSV/TSV, Excel-compatible. | | `injectStyles(document?)` / `avGridCss` / `AVGRID_STYLE_ID` | The stylesheet, if you would rather place it yourself. | | `Observable` / `Model` / `Subscription` / `AsyncRef` | The framework-free primitives the models are built on. | --- ## The engine The virtualization engine is exported and usable with no grid on top — `RenderGrid`, `RenderGridModel`, `CellPool`, `calcRenderInfo`, `prepareRerender`. That is how the 100k-row benchmark harness in `test-boards/RenderGridTest/` drives it. ```js import { RenderGrid } from "av-grid"; const engine = new RenderGrid(host, { rowCount: () => 100_000, columnCount: () => 20, rowHeight: 24, height: "100%", renderCell: (p) => { const el = p.previous ?? p.recycle?.() ?? document.createElement("div"); el.className = "my-cell"; el.textContent = `${p.row}:${p.col}`; return el; }, }); ``` `grid.render.stats` reports `paints`, `cellsAppended`, `cellsRemoved`, `lastPaintMs`, `totalPaintMs` and the pool's hit/miss counts — which is how any change to the render path is judged. **A cell renderer must resolve its element in this order:** ```js const el = p.previous ?? p.recycle?.() ?? document.createElement("div"); ``` `p.previous` is the element already at this coordinate, present when the cell went dirty rather than scrolled in — updating it in place means the paint does no DOM insertion or removal, and anything living on the element survives. `p.recycle()` is an element evicted on an earlier frame; it comes back **dirty**, exactly as its last occupant left it, so overwrite every property you set. Dropping `previous` costs ~12× on full repaints; dropping `recycle` allocates on every scroll frame. ### Reuse keys — when the rows are not all alike A grid's cells are interchangeable, so any pooled element will do. A **list whose rows differ in structure** — a log of text, tables, images and errors; a notebook of mixed cells — is the case that breaks: an element built for one kind handed to a row of another has to be torn down and rebuilt, which costs more than the allocation the pool saved. So a renderer may stamp each cell with an opaque key of its own and ask for a matching one back. Both calls are optional and both are one line: ```js const kindOf = new WeakMap(); // cell element -> the kind it was built for renderCell: (p) => { const kind = entries[p.row].type; // "text" | "table" | "image" | "error" // `previous` is still preferred — but only when it was built for this kind. const previous = p.previous && kindOf.get(p.previous) === kind ? p.previous : undefined; const cell = previous ?? p.recycle?.(kind) ?? document.createElement("div"); p.setReuseKey?.(cell, kind); kindOf.set(cell, kind); if (cell !== previous) buildRow(cell, entries[p.row]); return cell; } ``` | | | |---|---| | `p.recycle(reuseKey?)` | With a key, only cells released under that same key are eligible; a request that finds none returns `undefined` — a miss, and a `createElement`, rather than a cell of the wrong shape. With no key, any pooled element will do, exactly as before. | | `p.setReuseKey(el, reuseKey?)` | Declares what the cell ended up being built for, so the pool can hand it back to a compatible row. Called with no key, the cell returns to the untagged pool. | **The key is yours and the library never inspects it.** A string is the usual choice. Keys compare by `Map` semantics — identity for objects and symbols, value for strings and numbers. `p.previous` is **not** filtered by key: it is the element already at that coordinate, and whether it is still the right shape is a question only the renderer can answer — hence the guard in the snippet. Checking it is what makes a row that changed kind in place rebuild. A renderer that passes no key gets the pool it always had, down to the hit/miss counts in `grid.render.stats.pool`. Nothing about a homogeneous grid changes. ### Keeping a cell's state — `keepCellsAttached` Ordinary scrolling removes a cell from the DOM the moment it leaves the render window. For a data grid that is exactly right: the cells are inert, and detaching is the cheapest way to evict one. It is wrong for a cell that **owns** something. Removing a subtree destroys what is inside it, and the browser does not put it back on re-insertion. Measured on a cell holding a nested scroller and an `