Vanilla Grid Component
A lightweight, high-performance virtualized data grid component built with vanilla JavaScript (no dependencies).
Features
- Virtual Scrolling: Efficient rendering for large datasets (1000+ rows) using a recycled row pool; pool rows are rotated (not repopulated) on small scrolls, an engine-scoped virtual-height cap keeps large datasets in low-cost "natural" scroll mode on Blink/WebKit, and touch input gets a synchronous-render fast path plus a velocity-skewed buffer for smoother mobile momentum scrolling
- Column Resizing: Interactive column width adjustment with persistence; body columns update visually in real time during drag
- Resize Guide Overlay: Live vertical guide line aligned to the active resize handle, scoped to the viewport height, with automatic contrast via
mix-blend-mode: difference - Last Column Resize: Dedicated right-edge handle on the last column for independent resizing; table width auto-adjusts to remove/restore horizontal scroll as needed
- Horizontal Scrolling: Custom horizontal scrollbar when total column widths exceed the viewport, synchronized with header translate
- Header Horizontal Sync: Header stays aligned with body columns during horizontal scroll via GPU-accelerated
translateXtransform - Multi-Column Sorting: Client-side and server-side sorting, single-click or Shift+click multi-sort, with a customizable
compareValuescallback; sortable columns also get Sort ascending / Sort descending / Clear sorting items in the right-click header context menu - Column Reordering: Drag-and-drop header reordering (mouse and touch) with cross-group boundary enforcement for frozen columns; persisted to localStorage
- Column Visibility: Hide/show individual columns from a right-click header context menu or programmatically; at least one column is always visible; persisted to localStorage
- Column Freeze/Unfreeze: Sticky frozen columns anchored at the left edge with a visual guide line; context menu and API support; persisted to localStorage
- Row Selection:
noselection,single, andmultiplemodes; stable key-based selection survives sort, page, and infinite-scroll reloads; header checkbox andselectionChangedevent - Row Grouping (client-side, multi-level):
groupByColumn()/addGroupLevel()/setGroupState()collapse runs of equal values under a collapsible, nested caption row carrying the value and an exact member count; composes with sorting/filtering/selection/freeze/export; persists across reloads; "Group by this column" / "Add to grouping" / "Ungroup all" in the right-click header context menu, reason-coded and disabled over a partial (infinite-scroll) dataset rather than silently wrong. Each applied group level's column leaves the grid (its value lives in the caption) and becomes a chip in a grid-owned group bar above the header carrying its direction and an ungroup action, with "Expand all" / "Collapse all" / "Ungroup all" commands at the strip's trailing edge; chips can be dragged within the bar (or moved withAlt+arrow) to re-nest the levels; ungrouping restores the column at its original position, width, freeze state and filter - Loading Skeletons: Shimmer skeleton rows shown during data fetch replace the spinner pattern
- Infinite Scroll: Automatic page-chaining via
onLoadMore; optional total-row resolver; sticky select-all that extends to new pages - DataManager Pattern: Swappable data layer with
ODataDataManager,GraphQLDataManager, andStaticDataManagerbuilt-in - Web Component Wrapper: Optional
<vn-grid>custom element for declarative integration including<vn-grid-column>children - Export to Excel:
exportToExcel(options)method to export grid data (all rows or selected) to an.xlsxfile, written by the grid itself (no third-party library). Rows are read in short chunks and the workbook is streamed through the browser's native compression — in a Web Worker fromworkerThresholdrows (default 5000) — so the page stays responsive and exports scale to Excel's own sheet limit (about 3 s for 100,000 rows × 21 columns, 30 s for 1,000,000). Themed to the active theme by default; the overlay shows progress and a Cancel button; failures reject with a codedVanillaGridExportErrorand are shown in the overlay. See Export to Excel. - Cell Rendering: per-column
renderCellcallback for full control over per-cell DOM - Themeable: Eight pre-built themes (Default, Material, Fiori, Carbon, Carbon Dark, Glow, Glow Dark, Fluent) plus host-registered custom themes; custom scrollbar and resize guide automatically adapt to theme colors
- Custom Scrollbars (Vertical + Horizontal): JavaScript-driven dual-axis scrollbars with styled thumbs, drag support, and consistent cross-browser behavior
- Responsive: Adapts to container size with smooth scrolling; flex layout chain prevents white gaps during browser resize
- Customizable Formatting: Full control over cell value rendering, header labels, and secondary header text
- Mobile Touch Hardening: Text-selection/long-press callout suppressed on header and body cells (opt back in per-grid via
--vn-grid-cell-user-select), tap-highlight flash and header double-tap zoom disabled, and column-filter inputs auto-size to 16px on coarse-pointer devices to avoid iOS's input-focus page zoom - Zero Dependencies: Pure JavaScript, no frameworks needed
Installation
Direct File Include
<script src="path/to/vanilla-grid/vanilla-grid.js"></script>
vanilla-grid.js is the only tag needed: it auto-loads, relative to its own URL, every features/*.js module, the data managers (data-managers/data-manager.js, odata-data-manager.js, graphql-data-manager.js, static-data-manager.js) and, last, the <vn-grid> wrapper vanilla-grid-element.js. Await window.VanillaGridReady before using any of them. For a single-file deployment use the built vanilla-grid.bundle.js instead (it sets window.VanillaGridSkipAutoload = true internally).
When using the <vn-grid> web component, you do not need to add <link> tags for vanilla-grid.css or the theme files. The component automatically injects the base structural stylesheet and the active theme stylesheet into <head>.
If you are using VanillaGrid directly (without the web component), include the CSS manually:
<link rel="stylesheet" href="path/to/vanilla-grid/vanilla-grid.css">
<link rel="stylesheet" href="path/to/vanilla-grid/themes/vn-grid-default.css">
Usage
Web Component Setup
The custom element is designed as a light-DOM wrapper around VanillaGrid. It does not use Shadow DOM, so existing app and theme CSS continue to apply.
Markup declares presentation; data always comes from a DataManager (see DataManager):
<vn-grid
id="myGrid"
theme="default"
locale="en-US"
infinite-scroll="true"></vn-grid>
Theme Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
theme |
string | 'default' |
Built-in theme name: default, material, fiori, carbon, carbon-dark, glow, glow-dark, fluent (or a name added with VanillaGridElement.registerTheme()) |
theme-css-path |
string | — | Custom CSS file path. When set, overrides the theme attribute |
Supported themes are also available programmatically:
// List all built-in themes
console.log(VanillaGridElement.SUPPORTED_THEMES);
// → ['default', 'material', 'fiori', 'carbon', 'carbon-dark', 'glow', 'glow-dark', 'fluent']
// Switch theme at runtime
document.getElementById('myGrid').setTheme('carbon-dark');
// Or use a custom theme CSS file
document.getElementById('myGrid').themeCssPath = 'my-themes/grid-custom.css';
Attach a DataManager, initialize, then load:
await window.VanillaGridReady;
const myGridElement = document.getElementById('myGrid');
myGridElement.setDataManager(new ODataDataManager({
baseUrl: 'http://localhost:3000/api/items',
countUrl: 'http://localhost:3000/api/items/$count',
headers: () => ({ Accept: 'application/json' }),
pageSize: 1000
}));
const grid = myGridElement.initializeGrid({
layout: {
rowHeight: 24,
bufferRows: 20,
},
infiniteScroll: { enabled: true }
});
await myGridElement.loadRowsAsync();
Request headers and fetch side effects are DataManager concerns (headers, onRowsLoaded, onFetchResponse, …); per-cell DOM is a column's renderCell.
Basic Setup
<div class="table-container">
<div class="table-header-spacer">
<table class="header-table">
<colgroup id="myColGroup"></colgroup>
<thead id="myTableHeader"></thead>
</table>
</div>
<div id="myViewport" class="virtual-list-viewport">
<table class="body-table">
<colgroup></colgroup>
<tbody id="myTableBody"></tbody>
</table>
</div>
</div>
const grid = new VanillaGrid({
header: document.getElementById('myTableHeader'),
body: document.getElementById('myTableBody'),
headerColGroup: document.getElementById('myColGroup'),
viewport: document.getElementById('myViewport'),
headerSpacer: document.querySelector('.table-header-spacer'),
formatting: {
locale: 'en-US',
emptyMessage: 'No data available'
},
layout: {
rowHeight: 24,
snapViewportToRows: true,
bufferRows: 20,
scrollSpeedMultiplier: 1.35
}
});
// Set columns
grid.setColumns([
{ key: 'id', label: 'ID', width: 60 },
{ key: 'name', label: 'Name', width: 200 },
{ key: 'email', label: 'Email', width: 250 }
]);
// Load data
grid.setRows([
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
]);
Configuration Options
DOM References (top-level, required)
| Option | Type | Default | Description |
|---|---|---|---|
header |
HTMLElement | required | Table <thead> element |
body |
HTMLElement | required | Table <tbody> element |
viewport |
HTMLElement | required | Scrollable viewport container |
headerColGroup |
HTMLElement | required | <colgroup> for header columns |
headerSpacer |
HTMLElement | required | Spacer div for scrollbar alignment |
groupBar |
HTMLElement | optional | Strip the row-grouping group bar renders into. <vn-grid> always supplies one; omit it and the grid creates the strip itself as the first child of its .vn-grid-table-container. Only a grid with no resolvable container keeps its grouped columns visible instead of removing them |
Cell text is formatted by column type (with per-column formatOptions), and type: 'number' columns are right-aligned; per-cell DOM customization is the column's renderCell(cell, value, row, rowIndex) (see Column Definition).
formatting Group
| Option | Type | Default | Description |
|---|---|---|---|
formatting.locale |
string | navigator.language |
Locale used by default number formatting |
formatting.emptyMessage |
string | 'No rows to display.' |
Message shown when there is no data |
formatting.messages |
object | see below | Localizable labels for built-in UI text |
formatting.getHeaderMainText |
function | column.label ?? column.key |
Override the primary header label: (column) => string |
formatting.getHeaderSecondaryText |
function | [column.secondaryLabel] |
Override the bracketed secondary header text: (column) => string |
formatting.formatInteger |
function | Intl.NumberFormat |
Formats row counter integers in the scroll indicator |
formatting.formatScrollIndicator |
function | null |
Fully custom scroll position text formatter |
layout Group
| Option | Type | Default | Description |
|---|---|---|---|
layout.rowHeight |
number | 24 |
Height of each row in pixels |
layout.headerHeight |
number | theme default | Explicit column-header height in pixels. When set, overrides the active theme's --vn-grid-header-height. When omitted, the grid adopts the theme value (32px default, 40px Glow, 42px Fluent, 44px Fiori, 48px Carbon, 56px Material). |
layout.snapViewportToRows |
boolean | true |
Snap viewport body height to a multiple of row height to avoid partial-row viewport artifacts |
layout.bufferRows |
number | 20 (40 on pointer: coarse devices) |
Extra rows rendered above/below the visible area for smooth scrolling; skewed toward the scroll direction under high recent velocity (e.g. touch momentum) |
layout.scrollSpeedMultiplier |
number | 1.35 |
Wheel scroll speed multiplier (1.0 = slower, 1.8 = faster) |
layout.scrollSpeed |
string | 'standard' |
Legacy preset alias: 'slow', 'standard', 'fast' (ignored when scrollSpeedMultiplier is set) |
layout.customScrollbar |
boolean | true |
Use the custom JavaScript vertical scrollbar; set false for native browser scrollbar |
layout.stretchToFit |
boolean | false |
When true, grow the last resizable column so the column-width sum fills the viewport exactly. When false (default) the table stays at its natural width: leftover space is left empty and an oversized column sum produces a horizontal scrollbar. Can also be set as the top-level option stretchToFit or via the stretch-to-fit attribute on <vn-grid>. |
sorting Group
| Option | Type | Default | Description |
|---|---|---|---|
sorting.enabled |
boolean | true |
Enable column sorting |
sorting.serverSide |
boolean | false |
Skip client-side re-sort; delegate to the DataManager's handleSort() (which fires a config-change → auto-reload re-fetches page 0) or to the host's onSort |
sorting.onSort |
function | null |
Sort callback: (column, direction, sortState) => void |
sorting.compareValues |
function | built-in | Custom sort comparator: (a, b, column) => number. Must be a consistent ordering (transitive, 0 exactly for values that belong together) — grouping finds group boundaries with it. May order by any column property, including host-invented ones, except the display-only ones (labels, sizing, renderCell, formatOptions, timeZone, sortable, filter properties): setColumns() re-sorts when such a property of a sorted or grouped column changes. A comparator that depends on a display-only property or on state outside the column must re-sort itself with sortColumns(getSortState().sortColumns, { preserveScroll: true }), which also calls onSort and persists the sort state. |
sorting.shimmerThreshold |
number | 5000 |
Show the loading shimmer and defer a local sort by one animation frame when rows.length meets or exceeds this threshold (so very large datasets don't appear to freeze the UI). Set to 0 to always shimmer, false / Infinity / a negative number to disable. Server-sort mode is unaffected. |
sorting.columns |
array | undefined |
Initial sort chain, e.g. [{ key: 'lastName', direction: 'asc' }, { key: 'salary', direction: 'desc' }] — any number of columns, primary first. Key-based (unlike the index-based sortColumns() API). Also settable declaratively via the sort-by attribute on <vn-grid>. Entries naming an unknown, hidden or non-sortable column are skipped with a warning. A default, not a lock: the user's persisted sort — including "no sort" — wins afterwards. |
sorting.useWorker |
boolean | true |
Allow large local sorts to be offloaded to a lazily-created Web Worker so the main thread stays responsive. Automatically skipped when a custom compareValues is configured (functions can't cross the worker boundary) or when Worker is unavailable. |
sorting.workerThreshold |
number | 50000 |
Row-count threshold above which the sort is dispatched to the Web Worker. Same parsing as shimmerThreshold (false / Infinity / negative → disabled). |
grouping Group
Client-side, multi-level (any number of ordered group columns). See Row Grouping Implementation.
| Option | Type | Default | Description |
|---|---|---|---|
grouping.columns |
array | undefined |
Initial group state, e.g. [{ key: 'country', direction: 'asc' }, { key: 'city', direction: 'desc' }] — any number of ordered levels, outermost first. Also settable declaratively via the group-by attribute on <vn-grid>. A default, not a lock: the user's persisted grouping — including "no grouping" — wins afterwards. |
grouping.expandMode |
string | 'expanded' |
Whether a newly-encountered group starts 'expanded' or 'collapsed'. After expandAllGroups()/collapseAllGroups(), groups that appear later start in that state instead, until the grouping is fully cleared |
grouping.showCount |
boolean | true |
Include the member count in each caption's label |
Persistence: persistence.groupState ({ enabled, storageKey }) persists the requested group state across reloads, mirroring persistence.sortState — see the persistence group below.
columns Group
| Option | Type | Default | Description |
|---|---|---|---|
columns.reorderable |
boolean | true |
Enable drag-and-drop column reordering |
columns.touchReorder |
boolean | true |
Enable touch hold-to-reorder on mobile |
columns.touchReorderHoldDelay |
number | 220 |
Milliseconds to hold before touch reorder activates |
columns.reorderMarkerDeadZonePx |
number | 20 |
Dead-zone in pixels around the center of a column before the drop indicator flips sides |
persistence Group
| Option | Type | Default | Description |
|---|---|---|---|
persistence.columnWidths.enabled |
boolean | true |
Persist resized column widths to localStorage |
persistence.columnWidths.storageKey |
string | auto | Override the localStorage key for column widths |
persistence.columnOrder.enabled |
boolean | true |
Persist column order to localStorage |
persistence.columnOrder.storageKey |
string | auto | Override the localStorage key for column order |
persistence.hiddenColumns.enabled |
boolean | true |
Persist hidden column state to localStorage |
persistence.hiddenColumns.storageKey |
string | auto | Override the localStorage key for hidden columns |
persistence.frozenColumns.enabled |
boolean | true |
Persist frozen column state to localStorage |
persistence.frozenColumns.storageKey |
string | auto | Override the localStorage key for frozen columns |
persistence.sortState.enabled |
boolean | true |
Persist the active sort chain to localStorage |
persistence.sortState.storageKey |
string | auto | Override the localStorage key for sort state |
persistence.groupState.enabled |
boolean | true |
Persist the requested group state to localStorage |
persistence.groupState.storageKey |
string | auto | Override the localStorage key for group state |
persistence.filterModel.enabled |
boolean | true |
Persist the active column-filter model to localStorage |
persistence.filterModel.storageKey |
string | auto | Override the localStorage key for the filter model |
persistence.searchTerm.enabled |
boolean | false |
Persist the free-text search term to localStorage (opt-in; transient session state) |
persistence.searchTerm.storageKey |
string | auto | Override the localStorage key for the search term |
infiniteScroll Group
| Option | Type | Default | Description |
|---|---|---|---|
infiniteScroll.enabled |
boolean | false |
Enable infinite-scroll paging mode |
infiniteScroll.pageSize |
number | 1000 |
Rows requested per page in infinite-scroll mode |
infiniteScroll.onLoadMore |
function | null |
Async page loader: (skip, pageSize) => Promise<rows> |
infiniteScroll.shimmerDelay |
number | 500 |
Grace period in ms before the load-more affordances appear during a page fetch — both the shimmer placeholder rows at the tail and the "loading more rows" banner; 0 = immediate, negative = show neither. The .vn-grid-loading-more class is never delayed |
Total row count (push, not infinite-scroll-nested)
| Option | Type | Default | Description |
|---|---|---|---|
onTotalRowCountChanged |
function | null |
Callback fired whenever the total row count for the current query changes: (count|null) => void. Pushed by the attached DataManager (see getTotalRowCount() below) — the grid never pulls/polls for it. Top-level, not nested under infiniteScroll, since it applies to any grid a DataManager is attached to. |
selection Group
| Option | Type | Default | Description |
|---|---|---|---|
selection.mode |
string | 'noselection' |
Row selection mode: 'noselection', 'single', or 'multiple' |
selection.rowKeyField |
string | 'id' |
Field name used as the stable row identity key |
selection.rowKeyGetter |
function | null |
Custom key resolver (highest priority): (row) => string|number |
selection.showCheckboxes |
boolean | true |
Show a checkbox column when selection.mode is 'single' or 'multiple' |
messages Default Values
messages: {
of: 'of', // Scroll indicator separator ("1–50 of 1000")
hideColumn: 'Hide column', // Header context menu
showAllColumns: 'Show all columns', // Header context menu
freezeColumn: 'Freeze column', // Header context menu
unfreezeColumn: 'Unfreeze column', // Header context menu
unfreezeAll: 'Unfreeze all', // Header context menu
loadingMore: 'Loading more rows…', // Infinite-scroll busy banner
groupByColumn: 'Group by this column', // Header context menu (row grouping — replace)
addToGrouping: 'Add to grouping', // Header context menu (row grouping — append a level)
ungroupColumn: 'Remove from grouping', // Header context menu (row grouping)
ungroupAll: 'Ungroup all', // Header context menu + the group bar's trailing command (row grouping — clear every level; shown only while grouped)
groupReasonDisabled: '…', // Disabled-menu-item title, keyed by canGroupByColumn()'s/canAddGroupLevel()'s reason code
groupReasonPartialDataset: '…', // ditto — dataset not fully loaded
groupReasonMultiLevelNotYetSupported: '…', // ditto — a second group column
groupReasonNoVisibleColumnsLeft: '…', // ditto — grouping would leave no visible data column
groupItemsSuffix: 'items', // Caption label suffix ("… — 42 items")
groupBlankValue: '(Blank)', // Caption label for a null/undefined group value
groupPathAriaSeparator: ', ', // Joins a nested group's path in caption/footer accessible names
groupBarLabel: 'Grouped by', // Group bar: leading label + the strip's aria-label
groupBarRemove: 'Remove from grouping', // Group bar: a chip's remove button
groupBarSortAscending: 'Ascending', // Group bar: a chip's direction control (asc)
groupBarSortDescending: 'Descending', // Group bar: a chip's direction control (desc)
groupBarSuspended: 'Grouping is paused:',// Group bar: tooltip prefix while suspended
groupBarExpandAll: 'Expand all', // Group bar: trailing command (disabled while suspended)
groupBarCollapseAll: 'Collapse all', // Group bar: trailing command (disabled while suspended)
aggregateFunctionLabels: { // Group footer rows: each function's full name — accessible name,
sum: 'Sum', avg: 'Average', // cell tooltip ("Average of Projects for Italy: 14.165") and header menu
median: 'Median', min: 'Minimum', max: 'Maximum',
countTrue: 'True count', countDistinct: 'Distinct count',
},
aggregateFunctionMarkers: {}, // Text markers (empty by default: built-in functions show SVG icons). An entry replaces the icon
// ({ avg: 'Ø' }), gives a registered function a marker ({ stdev: 'σ' }), or '' hides it
aggregateFooterAria: 'Group summary', // Group footer rows: the leading announcement of the row's accessible name
aggregateOfLabel: 'of', // Group footer rows: joins function to column ("Sum of Projects")
aggregateForLabel: 'for', // Group footer rows: joins column to group path in the cell tooltip
aggregatePathSeparator: ' › ', // Group footer rows: joins the tooltip's path values (theme-independent; RTL: ' ‹ ')
aggregateMenuLabel: 'Aggregate', // Header menu: the submenu's parent entry
aggregateRemove: 'Remove aggregate', // Header menu: rendered only while the column carries one
// Disabled-entry titles, keyed by canAggregateColumn()'s reason code and
// derived from it the way the groupReason* keys are.
aggregateReasonNoGrouping: 'Aggregates require an active grouping.',
aggregateReasonColumnUnsupported: 'This column’s type cannot be aggregated.',
aggregateReasonColumnNotFound: 'This column cannot be aggregated.',
aggregateReasonPartialDataset: 'Aggregates require the full result set to be loaded.',
aggregateReasonDisabled: 'Aggregates are unavailable.',
}
Infinite-scroll busy state
Once a page fetch has been in flight for infiniteScroll.shimmerDelay ms (default 500 — a fast backend paints neither), the grid shows two things:
- Skeleton rows at the tail of the data. These are positional — scroll away from the end and they are off-screen.
- A "loading more rows" banner pinned to the bottom edge of the grid, visible at any scroll position. It never covers the rows underneath (a load-more append leaves them valid), reads its label from
messages.loadingMore, and carriesrole="status"for screen readers. It hangs off the grid container, so it works standalone — no<vn-grid-toolbar>and no custom scrollbar required.
Set shimmerDelay to a negative number to suppress both and paint your own indicator off the class below, which is never delayed.
Styling hooks:
| Hook | What it is |
|---|---|
.vn-grid-loading-more |
Class toggled on .vn-grid-table-container for the duration of the fetch, with no grace period — it tracks isBusy()'s load-more half exactly. Style your own indicator off it. Distinct from .vn-grid-shimmer-loading, which means a full load (setLoading(true)) |
.vn-grid-load-more-banner |
The banner element (.vn-grid-load-more-banner-visible while shown), with .vn-grid-load-more-banner-shimmer (animated accent) and .vn-grid-load-more-banner-text (label) inside |
.vn-grid-load-more-skeleton-row |
The tail skeleton rows |
Both the class and the banner clear between failed retries, since the grid is genuinely idle while it waits out the backoff.
Column Definition
Each column object passed to setColumns() supports the following properties:
| Property | Alias(es) | Description |
|---|---|---|
key |
field |
Required. Unique column identifier, also used as the field name when reading row data |
label |
— | Header display text (falls back to key if omitted) |
secondaryLabel |
secondary-label |
Secondary header text, shown in brackets after the main label on the same line, e.g. [kg] |
width |
— | Initial column width in pixels |
type |
— | Data type (string, number, boolean, date, datetime, time, uid, …) — drives formatting, alignment and the sort comparator. Default 'string' |
formatOptions |
— | Options forwarded to Intl.NumberFormat (number) or Intl.DateTimeFormat (temporal types) |
renderCell |
— | Custom cell renderer (cell, value, row, rowIndex) => void — mutate cell in place |
sortable |
— | Set false to make this column non-sortable (default true) |
resizable |
— | Set false to suppress the resize handle on this column |
filterable |
— | Set false to make this column non-filterable (default true) — see Column Filters |
highlightSearchMatches |
— | Set false to opt this column out of highlightSearchMatches (see below) even while the grid-wide flag is on. undefined/omitted (default) inherits the grid-wide flag. No effect when the grid-wide flag is off |
filterFields |
filter-fields |
Comma-separated underlying field path(s) to filter on when they differ from key, OR-ed together (fan-out). Covers custom-rendered / remapped columns (one field) and composite columns (several) |
filterType |
filter-type |
Operator/literal type the filter uses (defaults to type) |
filterOperators |
filter-operators |
Comma-separated allow-list of filter operators offered for this column (subset of its type catalog, e.g. "in,equals"). Panel dropdown and normalization both honor it; absent/empty = full catalog — see Column Filters |
defaultFilterOperator |
default-filter-operator |
Operator preselected when the filter panel opens with no active filter on the column (e.g. equals on a string column instead of the contains type-default). Works standalone; when filterOperators is also set it must be within that list |
isKeyColumn |
keyColumn, is-key-column, key-column |
Mark as the stable selection key column (see Row Selection) |
Example:
grid.setColumns([
{ key: 'id', label: 'ID', width: 60, sortable: false },
{ key: 'name', label: 'Name', width: 200 },
{ key: 'weight', label: 'Weight', secondaryLabel: 'kg', width: 120 },
{ key: 'active', label: 'Active', width: 80, isKeyColumn: false }
]);
VanillaGrid Methods
Data Management
setColumns(columns)— Set grid columns. When a sorted or grouped column's new definition orders differently (itsfield/valueGetter,type,nulls,sourceFormat/inputPattern,booleanCoerceorsortFields/sortFieldTypeschanged), the rows are re-sorted under the same sort/group state — no sort or group event fires and the scroll position is kept. Re-declaring identical columns re-sorts nothing; note that an inlinevalueGetterre-created on every call counts as a change. See Sorting § 10.3setRows(rows)— Replace all grid data; resets scroll positionappendRows(newRows)— Append rows without resetting current state (used by infinite scroll)loadData(dataLoaderFn, options)— Load rows via async callback with built-in loading state, skeleton rendering, and error handlingoptions.onSuccess(data, elapsedMs)— Called after successful loadoptions.onError(error)— Called on failureoptions.onComplete()— Always called (finally)
loadDataProgressively(dataLoaderFn, options)— Progressive chunked rendering for large payloadsoptions.chunkSize(default200) — Rows per rendering chunkoptions.chunkDelay(default16) — Milliseconds between chunksoptions.onProgress(loaded, total)— Progress callbackoptions.onSuccess,options.onError,options.onComplete— Same asloadData
getLoadedRowCount()— Returns the number of rows currently loadedgetTotalRowCount()— Returns the current known total row count, orundefinedwhen unknownsetTotalRowCount(count)— Pushes the total row count for the active query (wired automatically from the attached DataManager)hasMoreRows()— Returnstruewhen more pages are available in infinite-scroll mode; alwaysfalsefor a non-infinite-scroll (static/in-memory) grid, including during the reset→requery window of a filter/sort/reload
State Management
setLoading(isLoading)— Toggle loading state (shows shimmer skeletons, disables scroll)showEmpty(message)— Display an empty-state messageshowError(message)— Display an error-state messageclearPersistedSettings(options?)— Wipe every grid-owned settings key (widths, order, hidden, frozen, sort state, group state, filter model, search term) from the active storage provider, reset the in-memory caches, and re-apply the declarative state — the columns from the most recentsetColumns()call plus the declaredgroup-by/sort-by(grouping.columns/sorting.columns) request. Returnsundefinedfor sync providers andPromise<void>for async providers. Pass{ deferToReload: true }when a data reload follows immediately: the sort and group state are cleared and the declarative ones re-parked but not applied, so the dataset is ordered once by that reload'ssetRows()rather than twice. Mirrors the option of the same name onclearSort(). See Local Storage Settings §6.5.refresh(options?)— Rebind every visible cell against the current rows (re-running formatters /renderCell) without querying the DataManager. Resets scroll position to the top-left by default; pass{ preserveScroll: true }to keep it. Useful after a live theme swap.destroy()— Remove all event listeners and clean up internal state
Multi-Column Sorting
sortColumn(columnIndex, direction, options)— Sort one column;directionis'asc','desc', ornullto clear; pass{ multi: true }to add to the existing sort chainsortColumns(sortColumns, options)— Apply a full multi-sort chain:[{ columnIndex, direction }]clearSort()— Remove all active sortinggetSortState()— Returns the full sort snapshot (see Multi-Column Sorting)
These four are index-based. To declare an initial sort — before the grid
has columns, from markup or from an options object — use the key-based
sort-by attribute or the sorting.columns option instead:
<vn-grid sort-by="lastName:asc, salary:desc"></vn-grid>
Entry order is the sort-chain order, primary column first, and the direction is required on every entry. See Sorting Implementation §12.1.
Column Visibility
hideColumn(key)— Hide a column by key; returnsfalseif it is the last visible columnshowColumn(key)— Restore a hidden columnshowAllColumns()— Restore all hidden columnsgetHiddenColumns()— Returns an array of hidden column keyscanHideColumn(key)— Returnstrueif hiding this column is permitted (at least one other visible column must remain)
All four mutators rebuild the virtual row pool and preserve the current scroll position — hiding or showing a column does not jump the user back to the top of a scrolled grid.
Column Freeze
freezeColumn(key)— Freeze a column (it moves to the left sticky group)unfreezeColumn(key)— Unfreeze one columnunfreezeAll()— Unfreeze all columnsisFrozenColumn(key)— Returnstrueif the column is currently frozengetFrozenColumns()— Returns an array of frozen column keys
Column Reordering
reorderColumn(fromIndex, toIndex, options)— Move a column programmatically; pass{ dropBefore: true|false }to control insertion side
Header Runtime
refreshHeaderLayout(options?)— Re-render the header and synchronize header/body widths, scrollbar spacing, and tooltips; pass{ clearExistingWidths: true }to reset all widthsupdateColumnHeaders(updater)— Batch-update column metadata; callsrefreshHeaderLayout()once if any change is detected;updater(column, index)should returntruewhen it modifies a columnsetColumnSecondaryLabel(columnKey, secondaryLabel)— Update one column'ssecondaryLabeland refresh the header layout
Row Selection
getSelectedKeys()— Returns an array of the currently selected row keysgetSelectedRows()— Returns an array of the currently selected row objects (O(1) lookup via internal key map)setSelectedKeys(keys)— Replace the current selection with the given keys arrayclearSelection()— Deselect all rowsisRowSelected(row)— Returnstrueif the given row object is currently selectedselectionState(getter) — Returns'none','partial', or'all'selectAllActive(getter) — Returnstruewhen the sticky select-all flag is active (all incoming rows will also be auto-selected; relevant for infinite scroll)
Row Grouping
Client-side, multi-level. See Row Grouping Implementation for the full design — the render projection, availability gating over a partial dataset, and the caption keyboard/focus contract.
groupByColumn(key, options?)— Replaces the whole group state with this one entry ("Group by this column" in the header menu), even if the grid is already grouped by other columns;options.directionis'asc'(default) or'desc'. Returns{ available, strategy, reason }.addGroupLevel(key, options?)— Appendskeyas a new trailing group level instead of replacing ("Add to grouping" in the header menu) — the interactive way to build multi-level grouping one column at a time. No-op ifkeyis already a group level. Sameoptions/return shape asgroupByColumn.ungroupColumn(key)— Removekey's level from the group state, wherever it sits, leaving every other level intact and in order.clearGrouping()— Remove grouping unconditionally ("Ungroup all" in the header menu, offered on every column's menu while any level is applied).getGroupState()— Returns the requested group state ([{ key, direction }, ...]), a defensive copy — intact even while grouping is unavailable (seecanGroupByColumn).setGroupState(state)— The general-purpose multi-level entry point. Replaces the full group state with any number of ordered levels (outermost first) in one call — whataddGroupLevel()/groupByColumn()are themselves built on.expandGroup(path, options?)/collapseGroup(path, options?)— Expand or collapse one caption by its raw-value path, outermost first (e.g.['Italy'], or['Italy', 'Rome']for a nested level).options.descendants('collapsed'or'expanded') also sets every group below it, at every depth, in one step:expandGroup(['Italy'], { descendants: 'collapsed' })opens Italy showing only its folded city captions. All or nothing —false, with nothing changed, when the subtree holds more groups than the cardinality ceiling or the grouping isn't applied.- Shift-click a caption, or press Shift+Enter on a focused one, to fold or unfold the groups below it. The caption always ends up open: a collapsed caption, or an open one with any open child group, opens with everything below folded; an open one whose child groups are all folded opens everything below. At the deepest level it's a plain toggle. Captions that offer it carry a hover hint (
messages.groupCaptionToggleHint) andaria-keyshortcuts="Shift+Enter". Shift rather than Alt because Linux window managers (Xfce by default) take Alt+click. expandAllGroups()/collapseAllGroups()— Expand or collapse every group at every level — nested groups under a collapsed parent included — in one call (also the group bar's "Expand all" / "Collapse all"). The state also becomes the one groups that appear later start in, instead ofgrouping.expandMode, until the grouping is fully cleared. Fires onevn-grid-group-expanded/vn-grid-group-collapsedwith{ path: null, groupKey: null, all: true, descendants: null }, or nothing when every group was already in that state.canGroupByColumn(key)— O(1) predicate, never scans rows. WhethergroupByColumn(key)(replace) could apply right now. Returns{ available, strategy, reason };reasonis one of'ok','disabled','partial-dataset','multi-level-not-yet-supported', or'column-unsupported'. Reports'multi-level-not-yet-supported'whenever grouped by a different column — seecanAddGroupLevel.canAddGroupLevel(key)— O(1) predicate, never scans rows. WhetheraddGroupLevel(key)(append) could apply right now — only available once some grouping already exists andkeyisn't already one of its levels. Same return shape ascanGroupByColumn, minus the'multi-level-not-yet-supported'reason.'too-many-groups'is only ever returned by the three mutating calls (groupByColumn/addGroupLevel/setGroupState), never by either predicate, when a requested grouping would exceed the fixed cardinality ceiling — the group state is left unchanged.getGroupingStatus()— Returns{ available, strategy, reason }for the current requested state, resolved fresh with no side effects.getGroupState()answers what was requested; this answers whether it is applied right now, and why not when it isn't.
Large grids: a group change is a reorder, and reorders defer. A group change moves as much data as a sort, so it runs on the same bracket: below sorting.shimmerThreshold (default 5000 rows) it is fully synchronous; at or above it the reorder is deferred behind the loading skeleton and offloaded to the sort Web Worker when every group level is worker-safe (string/number/boolean; a temporal group level falls back to the shimmered in-thread path). Two consequences for callers:
- On a deferred call,
groupByColumn()/addGroupLevel()/setGroupState()return the capability resolvable before the row scan, so a'too-many-groups'rejection arrives onvn-grid-group-changedinstead of in the return value.'no-visible-columns-left'is pure column arithmetic and is always in the return value. Read the event if you need the outcome on grids of any size. vn-grid-group-changedfires when the new order has actually landed, not when the call returned.
Events: vn-grid-group-changed, vn-grid-group-expanded, vn-grid-group-collapsed, vn-grid-aggregates-changed (all bubbling + composed — see the implementation doc's Event Contract section).
Group aggregates
A group footer row under each group, at every level, carrying a named reduction of one column's values in that column's own cell. Configured declaratively or at runtime; nothing renders until at least one column carries one.
new VanillaGrid({
grouping: {
columns: [{ key: 'country', direction: 'asc' }],
aggregates: [{ key: 'projects', fn: 'sum' }]
}
});
grouping.aggregates— Initial set,[{ key, fn }]withfna registered reducer name. A default, not a lock: the user's persisted choice wins afterwards, andclearPersistedSettings()restores it. Applied aftergrouping.columns, so it has no effect on a grid with no grouping. There is no<vn-grid>attribute for it.getAggregates()— The configured set, a defensive copy.setAggregates(state)— Atomically replace it. Returns{ available, reason }, wherereasonis one of'ok','column-not-found','unknown-function','column-unsupported','column-grouped','no-grouping'or'disabled'. Every refusal is resolved here rather than at render time, and it is all-or-nothing: one bad entry leaves the whole set untouched. Two entries naming the same column are not a refusal — the last wins. Clearing issetAggregates([]), accepted in every state.getGroupAggregates(path)— One group's computed values by column key, taking the same raw-value pathexpandGroup()does. A column with nothing to reduce reportsnull, never0.canAggregateColumn(key)— O(1) predicate, never scans rows. Returns{ available, reason, functions }, wherefunctionsis every registered function the column's declared type admits. This is what the header menu greys its entry out with; itsreasonset is wider thansetAggregates()'s (it can report'partial-dataset'for a suspended grouping), the same waycanGroupByColumn()'s is wider thansetGroupState()'s.VanillaGrid.registerAggregate(name, reducer)— Static. Extend the registry with{ init, step, finalize, types }, plus two optional members:prepare(context)andresultType.typeslists thecolumn.typevalues the function may be applied to, so a host reducer passes the same eligibility gate the built-ins do. See The reducer contract below.
From the header menu. A column's right-click menu carries "Aggregate ▸", opening a flyout of the functions its type admits, plus "Remove aggregate" while one is set. Picking a function on a column that already has one replaces it. The entry is on every column's menu whatever the grid state — so the capability is discoverable before anything is grouped — and is disabled with an explained title when it cannot act: the column's type first ('column-unsupported'), then the grid's state ('no-grouping', or 'partial-dataset' while a grouping is suspended). A column that is ineligible both ways reports its type, since that is the reason grouping will not fix. A grouped column needs no rule: its column has left the grid, so its menu is not on screen.
What a v1 aggregate deliberately does not do, so none of it is a discovery:
- No plain
count. The caption already carries the member count of every group.countTrueandcountDistinctcount something the caption does not. - No grand total, as a row or through the API.
getGroupAggregates([])is the reserved address for one when a host asks. - No group footer rows in the Excel export. The export is a data export of
displayRowswith the group columns composed back in, so captions, collapse state and footers are all structurally absent. The recipient reproduces the totals as live formulas with Excel's own Data ▸ Subtotal, which is a better artifact than pasted numbers — and writing them as cells would break the sheet's AutoFilter. - No server-side aggregation — it belongs with server-side grouping, which is also not implemented.
- No cross-column formulas. A reducer's
step(acc, value)receives a value, not a row: this is a named reduction over one column, not an expression language. - No per-column override of the function set. The type gate screens what is computable, never what is meaningful, so every statistic is offered on every
numbercolumn — an ID or a rating included. That judgment is the host's and the user's. - A partial sum looks exactly like a complete one. In practice grouping suspends over an incomplete result set, so an aggregate is only ever computed over the full dataset.
- Nothing visible on a footer names the group it closes — hovering an aggregated cell does (see below), and so does its accessible name, but at a glance a sighted user who has scrolled past the caption sees only the totals.
Tabreaches only the group rows currently in the virtual pool. Pre-existing behaviour of caption toggles; footers add stops within the pool without changing it.- Aggregates never reflect the selection. Recomputing per click is a full projection build, 165–433 ms of main thread at a million rows.
Seven functions ship built in, offered by the column's declared type — the gate reads the declaration, never the runtime values, so an untyped or valueGetter column offers nothing. Registration order is menu order.
column.type |
sum |
avg |
median |
min |
max |
countTrue |
countDistinct |
|---|---|---|---|---|---|---|---|
number |
✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ |
date / datetime / time |
— | — | — | ✓ | ✓ | — | ✓ |
string, uuid, uid |
— | — | — | — | — | — | ✓ |
boolean |
— | — | — | — | — | ✓ | — |
- Gaps are skipped, never coerced, and a group with nothing to reduce renders blank, never
0— foravgthat matters more than forsum, since anullcounted as zero would pull the average down. Every level reduces its own rows, never its children's results: an outeravg,medianorcountDistinctis the true value over the group, not an average of averages. avgandmedianuse the column's own formatter, so an average can show decimals no data cell above it shows (an average of integers is not an integer), and amaximumFractionDigits: 0column rounds it.medianis exact — it keeps each open group's values — never an approximation.- Temporal
min/maxread each value the way the column's own cells read it (epoch numbers, ISO strings,sourceFormat,inputPattern) and report the winning row's raw value, so the footer renders exactly like that row's cell andgetGroupAggregates()returns the raw value (a number, an ISO string, aDate), not an epoch. countTruereads values through the column'sbooleanCoerce(so'Yes'/'No'count under'loose');0is a real answer, blank means no known value at all.countDistinctcounts with grouping's equality — the column's comparator — soRome,romeandRómeare one city, exactly as grouping by City shows one group. Both render as right-aligned integers whatever the column's type.- Hovering an aggregated footer cell shows the function's full name, the column, the group's full path and the value — "Average of Projects for Italy › Rome: 14.165". Each parent value longer than 28 characters is cut at the end with
…; the innermost value never is. The localized pieces are the row's accessible name's, plusaggregateForLabelandaggregatePathSeparator. - The header says so, too. While a column's footer cells show a total, its header carries a badge with the same marker (
.vn-grid-aggregate-badge, after the secondary header text), and itstitlenames the function ("Average of Height"). The badge follows every change to the set and disappears while grouping is suspended. A marker hidden with''inaggregateFunctionMarkershides it in the footer only; the badge keeps the built-in icon. - Markers are icons. A built-in function's marker is a masked SVG (
.vn-grid-aggregate-iconwithdata-fn), not a font glyph, so its weight and centering are the same in every theme. The shapes are--vn-grid-aggregate-icon-*tokens a theme can override (see Header parts). Amessages.aggregateFunctionMarkersentry replaces an icon with text. - Nested groups are announced by their full path. A nested caption's toggle and footer name every level ("Country: Italy, City: Rome — 12 items"), so two groups with the same inner value are told apart; the caption's visible text is unchanged and ends the announcement.
Cost at a million rows. In Chrome, on grid-minimal-js grouped by Country ▸ City, a whole aggregate change — the grouped rebuild included — settles in about 0.8–0.9 s for Median of Height plus Distinct count of Department, and about 0.9–1.2 s for Minimum of Hire Date plus True count of Active, with or without the --obfuscate build. Per function, one aggregated column adds roughly: sum, avg, min, max 30–60 ms; median 1.2–1.8× that; countTrue and low-cardinality countDistinct 60–110 ms; temporal min/max on epoch-number columns about the same as sum (per-function figures from a Node harness). Two cases are much dearer; both run behind the loading skeleton and only when someone picks that function on such a column:
countDistincton a string column of mostly unique values — about 23–26 s in Chrome for 1M unique emails at two group levels. Almost all of it is the main thread taking in each distinct string for the first time (hashing it into the exact-value set); the locale-aware sort of the uniques is a small part. A Node harness shows only +1.4 s (one level) / +4.4 s (two levels) for the same work, so do not size this from Node figures.countDistincton numbers or epoch dates is unaffected (about +0.1–0.4 s even at 1M unique values), and so is a low-cardinality string column.- ISO-string temporal columns under
min/maxpay one parse per distinct string: about +0.2–0.6 s for a date column's few thousand distinct days, +1.6–2.1 s for one distinct timestamp per row (Node harness; not yet measured in a browser, where it may be higher).
Aggregates are single-column: a reducer's step(acc, value) receives a value, not a row, and there is no cross-column expression language.
The reducer contract. { init(context), step(acc, value), finalize(acc), types }, plus optionally:
context—{ column, compare(a, b) }, frozen, built once per column per projection build and handed toinit()andprepare().compareis the grid's comparator for that column, the one grouping splits groups with.prepare(context)— a factory called once per column per build, returning a per-row mapper applied beforestep()(once per row, however deep the grouping) ornullfor a column that needs no conversion, which then costs nothing extra.resultType—'column'(default: formatted and aligned by the column) or'count'(formatted withformatting.formatInteger, right-aligned whatever the column's type).
step() runs once per row per open group level; state size is the reducer's business.
// Sample standard deviation (Welford), skipping gaps like the built-ins.
VanillaGrid.registerAggregate('stdev', {
init: () => ({ n: 0, mean: 0, m2: 0 }),
step: (acc, v) => {
if (typeof v !== 'number' || Number.isNaN(v)) return;
acc.n++;
const d = v - acc.mean;
acc.mean += d / acc.n;
acc.m2 += d * (v - acc.mean);
},
finalize: (acc) => (acc.n > 1 ? Math.sqrt(acc.m2 / (acc.n - 1)) : null),
types: ['number'],
});
A registered function's label and marker come from messages.aggregateFunctionLabels / aggregateFunctionMarkers, and its marker is text (only the built-ins have icons); with neither, the menu and tooltip show its name, the cell no marker, and the header badge the sum icon. samples/grid-minimal-js/ registers two, stdev and mode, as working examples.
Like a group change, an aggregate change runs on the reorder bracket: fully synchronous below sorting.shimmerThreshold, behind the loading skeleton at or above it. A change arriving while a group change is still deferred does not overtake it — the two coalesce into one settled pipeline, so the projection is always built from rows ordered for the grouping that was asked for, and each settled change is announced once. step() is main-thread work — a host reducer cannot be transferred to the sort Worker. vn-grid-aggregates-changed fires once per settled change and means the computed values have rendered, so getGroupAggregates() read inside the handler is always settled; a superseded recomputation emits nothing.
grid.setAggregates([{ key: 'projects', fn: 'sum' }]);
grid.addEventListener?.('vn-grid-aggregates-changed', () => {
console.log(grid.getGroupAggregates(['Italy'])); // { projects: 1204 }
});
An aggregate and a group level never share a column. An applied level's column has left the grid, so its total would have no cell to render into — which is why the header menu can never offer one. The API is held to the same rule at both ends: setAggregates() refuses a column the grid is grouped by ('column-grouped'), and grouping by an already-aggregated column prunes that aggregate with one event, rather than leaving a configuration that computes and renders nowhere. An ineligible column type still outranks the conflict.
An aggregate lives and dies with the requested group state — one rule, three cases. Clearing the last group level discards the configuration (there is nothing left to reduce over), emitting one vn-grid-aggregates-changed with the emptied set; removing one level of a multi-level grouping keeps it, since the session is still alive. A suspension retains it with no event and no host action, and resuming recomputes — but while suspended the set is not editable, and setAggregates() refuses with 'disabled'. A setColumns() that removes, renames or retypes an aggregated column prunes that entry, revalidating against the same gate a fresh acquisition passes, and announces the settled set with exactly one event — so no stale key ever reaches a footer cell. All of it persists inside persistence.groupState: levels and aggregates share one key, one flag and one lifetime, and a blob stored before aggregates existed reads back as none configured. A declared grouping.aggregates with no grouping beside it is warned about and ignored.
Grouped columns leave the grid. An applied group level's column is removed from columns — its value lives in the caption row, not in a column repeating it on every member row — and a group bar above the header renders one chip per level with its label, its direction, and an ungroup action. This is unconditional; there is no option for it.
Re-nesting: drag a chip. Chips are draggable within the bar, and dropping one on the other side of a sibling re-nests the grouping — dragging Country before City turns City ▸ Country into Country ▸ City, with the captions, the effective sort and the projection all re-deriving. Alt+ArrowLeft/ArrowRight (or Ctrl+Shift+arrow, for window managers that swallow Alt+arrow) does the same from the keyboard, in the logical direction, with focus following the level it moved. A drop is setGroupState() with a reordered array and nothing else: no new API, no new event, and a drop that would not change the order emits nothing at all. It is not gated on columns.behavior.reorderable — that flag governs column order, not group state. Pointer and keyboard only; touch is not supported for this gesture.
The bar's trailing edge carries three text commands acting on the whole grouping — Expand all, Collapse all and Ungroup all — each one calling the public API (expandAllGroups(), collapseAllGroups(), clearGrouping()), so a bar click is indistinguishable from a host call or a header-menu click. Expand/collapse are disabled while the grouping is suspended (no captions to act on); Ungroup all never is. Their appearance is theme-owned via --vn-grid-group-bar-action-*.
- A grouped column is not "hidden":
getHiddenColumns()never lists it,showAllColumns()never brings it back, andshowColumn(key)returnsfalsefor it.ungroupColumn(key)is what restores it — at its original position, saved width, freeze state and column filter. - Grouping a column that carries a user sort transfers that sort: the direction seeds the group level and the entry leaves the sort state (firing
vn-grid-sort-changed), so the sort is never silently deleted along with the column. Ungrouping does not restore it — the chip is where that direction lives now. - The Excel export re-composes the applied group columns back in, first and in group order, so a grouped grid never exports a sheet missing the field it is organised by. An explicit
exportToExcel({ columns })allow-list stays authoritative. - A grouping that would leave no visible, non-internal column is refused with
reason: 'no-visible-columns-left', from the predicates as well as the mutating calls, and the state is left unchanged. - A suspended grouping (partial dataset) gives its columns back and keeps its chips in a suspended style, so removing a level never requires fixing the dataset first.
- A grid with no resolvable
.vn-grid-table-container(hand-built markup) cannot mount the bar; it logs once and keeps its grouped columns visible. Grouping itself still works. The same applies to a partial bundle withoutfeatures/grouping-bar.feature.js.
While grouped, a column filter on a grouped column stays applied but can only be edited by ungrouping first (its funnel icon left with the header). The toolbar's hasFilterOrSort predicate and clearColumnFiltersAndSorting command still see and clear it.
Web Component API
vanilla-grid-element.js exposes VanillaGridElement, registered as <vn-grid>.
Declarative Columns
Columns can be declared as child elements instead of (or as a fallback to) calling setColumns() imperatively:
<vn-grid id="myGrid" infinite-scroll="true" selection-mode="multiple" row-key-field="id">
<vn-grid-column field="id" label="ID" width="60"></vn-grid-column>
<vn-grid-column field="name" label="Name" width="200"></vn-grid-column>
<vn-grid-column field="weight" label="Weight" secondary-label="kg" width="120"></vn-grid-column>
</vn-grid>
<vn-grid-column> attributes map directly to column definition properties: field (or key), label, width, secondary-label, is-key-column.
Observed Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
environment |
string | '' |
Host-defined value mirrored into lifecycle-event context.host.environment; the grid never reads it |
locale |
string | '' |
Locale hint passed to the grid (mirrored into lifecycle-event context.locale) |
max-rows |
string | '' |
Host-defined value mirrored into lifecycle-event context.host.maxRows; the grid never reads it |
infinite-scroll |
boolean string | 'false' |
Enables infinite loading mode when 'true' |
theme |
string | 'default' |
Built-in or registered theme name (see Theme Attributes) |
theme-css-path |
string | — | Custom theme CSS path; overrides theme |
storage-mode |
string | 'none' |
Settings persistence backend: 'none', 'local' (localStorage), or 'remote' |
empty-message |
string | default text | Initial empty-state text rendered before grid init (read once, not observed) |
selection-mode |
string | 'noselection' |
Row selection mode: 'noselection', 'single', or 'multiple' |
row-key-field |
string | 'id' |
Field used as the stable row identity key |
stretch-to-fit |
boolean string | 'false' |
When present (with no value, or 'true'/''), the last resizable column grows to fill the viewport. When omitted or 'false', columns keep their natural / configured / persisted widths. |
highlight-search-matches |
boolean string | 'false' |
When present (with no value, or 'true'/''), every visible cell whose formatted text contains the active search term gets that substring wrapped in <mark class="vn-grid-search-match">. Off by default. Per-column opt-out via column.highlightSearchMatches: false. See docs/vanilla-grid/02-row-virtualization-and-custom-scroll-implementation.md §1.17. |
row-height |
number (px) | theme default | Explicit body row height. When set (positive finite number), takes precedence over the active theme's --vn-grid-row-height and is forwarded as layout.rowHeight. When omitted, the grid adopts the theme's row height (24px default, 32px Fiori/Fluent/Glow, 36px Material, 48px Carbon). |
header-height |
number (px) | theme default | Explicit column-header height. When set (positive finite number), takes precedence over the active theme's --vn-grid-header-height and is forwarded as layout.headerHeight. When omitted, the grid adopts the theme's header height (32px default, 40px Glow, 42px Fluent, 44px Fiori, 48px Carbon, 56px Material). |
group-by |
string | unset | Initial multi-level grouping — a comma-separated key:direction list, outermost level first (e.g. "country:asc, city:desc"). The direction is required on every entry; an invalid entry is skipped with a warning while the rest still apply. Forwarded as grouping.columns at initializeGrid() time and read once — use groupByColumn() / setGroupState() on the live grid for runtime changes. |
sort-by |
string | unset | Initial multi-column sort — the same grammar as group-by, with entry order meaning sort-chain order, primary column first (e.g. "lastName:asc, salary:desc"). Forwarded as sorting.columns and read once — use sortColumns() / clearSort() afterwards. Entries naming an unknown, hidden or non-sortable column are skipped with a warning. |
Element Properties
| Property | Description |
|---|---|
selectionMode |
Get/set the selection mode ('noselection', 'single', 'multiple') |
rowKeyField |
Get/set the row key field name |
rowKeyGetter |
Set a custom key resolver function (row) => string|number |
rowHeight |
Get/set the explicit row height in pixels (mirrors the row-height attribute). Returns null when unset. |
headerHeight |
Get/set the explicit column-header height in pixels (mirrors the header-height attribute). Returns null when unset. |
groupBy |
Get/set the raw declarative grouping list (mirrors the group-by attribute), e.g. 'country:asc, city:desc'. Returns null when unset. |
sortBy |
Get/set the raw declarative initial-sort list (mirrors the sort-by attribute), e.g. 'lastName:asc, salary:desc'. Returns null when unset. |
highlightSearchMatches |
Read-only; reflects the highlight-search-matches attribute. |
Element Methods
initializeGrid(options)— Create an internalVanillaGridinstance bound to the element markup; returns theVanillaGridinstanceattachGridInstance(instance)— Attach an externally createdVanillaGridinstance to this elementsetDataManager(dm)— Attach aDataManagerinstance (OData, Static, or custom)getDataManager()— Returns the currently attachedDataManagersetGridOptions(options)— Merge reusable grid options without recreating the element markupreload(options?)— Re-query the attachedDataManagervialoadRowsAsync()and reset the scrollbars; pass{ preserveScrollBars: true }to keep themrefresh(options?)— Rebind every visible cell against the grid's current in-memory rows without querying the DataManager. Resets scroll position to the top-left by default; pass{ preserveScroll: true }to keep it. Unlikereload(), never touches the network.loadRowsAsync()— Load rows (awaitsready()first; recommended entry point)setColumns(columns)— Push columns into the internal grid instancesetData(rows)— Push rows into the internal grid instancesetTheme(theme)— Update thethemeattributegetGridElements()— Return internal DOM references used byVanillaGridhideColumn(key),showColumn(key),showAllColumns(),getHiddenColumns(),canHideColumn(key)— Column visibility forwarded to the internal gridfreezeColumn(key),unfreezeColumn(key),unfreezeAll(),isFrozenColumn(key),getFrozenColumns()— Column freeze forwarded to the internal gridgetSelectedKeys(),getSelectedRows(),setSelectedKeys(keys),clearSelection()— Selection API forwarded to the internal gridgroupByColumn(key, options?),addGroupLevel(key, options?),ungroupColumn(key),clearGrouping(),getGroupState(),setGroupState(state),expandGroup(path),collapseGroup(path),expandAllGroups(),collapseAllGroups(),canGroupByColumn(key),canAddGroupLevel(key),getGroupingStatus(),getAggregates(),setAggregates(state),getGroupAggregates(path),canAggregateColumn(key)— Row grouping API forwarded to the internal grid (see Row Grouping)clearPersistedSettings()— Wipe persisted layout settings and reset to declarative defaults. Always returnsPromise<void>(regardless of the underlying provider). Dispatchesvn-grid-persistence-clearedon success. Re-queries the DataManager afterwards only when the reset actually changed the query — an active column filter, an active search term, orserver-sort. With none of those in effect the rows the grid already holds are the rows the cleared state describes, so the backend is never contacted; use thereloadcommand when a re-fetch is what you want.
DataManager Hooks
The element has no fetch logic of its own — every load goes through the attached DataManager (see DataManager). A custom subclass overrides:
buildRequestHeaders()— Return the headers object for row/count requestsfetchRows()— Async row loader; return an array of row objectstransformRows(rows)— Optional row post-processing before renderonRowsLoaded(rows)— Side effects after rows are loadedhandleSort(column, direction, sortState)— Receives grid sort events; server-side managers update their query here
Element Events
vn-grid-attribute-changed— Fired when any observed attribute changes;detailcontains{ attribute, value, context }vn-grid-loading— Fired before rows are loadedvn-grid-loaded— Fired after rows are loaded successfullyvn-grid-error— Fired when row loading failsvn-grid-busy-changed— Fired wheneverisBusy()changes value, and only when it actually changes;detailcontains{ busy }. This is the whole busy lifecycle, which the three events above are not: they bracket a data fetch, while the grid is also busy whenever a large sort, group change or freshly-set dataset reorders behind the shimmer, and while an infinite-scroll page is in flight. Listen here — not tovn-grid-loading/vn-grid-loaded— for spinners, busy-gated controls and other "the grid is working" affordances; on a large gridvn-grid-loadedcan arrive while the rows are still being reordered. A grid-levelonBusyChanged(busy)callback is also available when usingVanillaGriddirectlyvn-grid-persistence-cleared— Fired afterclearPersistedSettings()finishes successfullyvn-grid-group-changed— Fired whenever the requested group state changes, or on a suspend/resume transition over a partial dataset;detail: { groupState, active, reason }vn-grid-group-expanded/vn-grid-group-collapsed— Fired when a group caption is expanded/collapsed;detail: { path, groupKey, all, descendants }.all: falsefor a single group (expandGroup()/collapseGroup()or its toggle);all: true, withpathandgroupKeybothnull, once for a wholeexpandAllGroups()/collapseAllGroups()or the group bar's Expand all / Collapse all.descendantsis'collapsed'/'expanded'for adescendantscall or a caption's Shift-click — one event however many groups changed, typed by the caption's resulting state (so a Shift-click on an open caption firesvn-grid-group-expanded) — andnullotherwisevn-grid-selection-changed— Fired on the viewport element when selection changes;detailis the full selection payload (see Row Selection)vn-grid-row-dblclick— Fired when a row is double-clicked;detailcontains{ row, rowIndex }. Double-clicking a row does not alter its selection statevn-grid-load-more-failed— Fired every time an infinite-scroll "load more" page fetch fails;detailcontains{ error, failureCount, retryDelayMs }. The grid retries automatically with an exponential backoff (1 s doubling to a 30 s cap) and never gives up — a successful load orreloadDataManager()resets the backoff. A grid-levelinfiniteScroll.onLoadMoreErrorcallback is also available when usingVanillaGriddirectlyvn-grid-load-more-succeeded— Fired every time an infinite-scroll "load more" page fetch resolves and its rows are appended (even when the page is empty);detailcontains{ rows, loadedRowCount, hasMoreRows }.appendRows()itself dispatches no event andvn-grid-loadedonly covers the initial load/reload path, so this is the signal to listen for if you need to reflect scroll-triggered pagination (e.g. a row-count status line). A grid-levelinfiniteScroll.onLoadMoreSuccesscallback is also available when usingVanillaGriddirectlyvn-grid-total-row-count-changed— Fired whenever the attached DataManager pushes a new total row count (initial load, then again after every reload/filter/search/sort change);detailcontains{ totalRowCount }(nullwhen unknown)export-ready— Not emitted;exportToExcel()returns aPromise<void>(resolves once the file has been handed to the browser, rejects with a codedVanillaGridExportError)
Column Resizing
Real-time Feedback
During a column resize drag, the body columns update in lock-step with the header. Both the header and body tables are kept at an explicit pixel width matching the sum of all column widths, ensuring table-layout: fixed distributes space immediately on every animation frame.
Last Column
Every column — including the last — has a resize handle on its right edge. The last column resizes independently (not paired with a neighbour), so dragging its handle left shrinks it without affecting other columns. After release, the table width is recalculated:
- If all columns fit within the viewport → no horizontal scroll.
- If columns exceed the viewport → horizontal scroll is enabled automatically.
Resize Guide Overlay
A thin vertical guide line appears during column resize drag:
- Positioned at the center of the active resize handle.
- Height is scoped to the scrollable viewport (not the full container).
- Color uses
mix-blend-mode: differencewith a white base, making it always visible regardless of theme background. - Hidden for the last column handle (where it would conflict with the horizontal scrollbar area).
Horizontal Scrolling
When the total column width exceeds the viewport, a custom horizontal scrollbar appears at the bottom of the grid. The header table stays aligned with the body during horizontal scroll via a GPU-accelerated transform: translateX() applied on every scroll event.
The custom vertical scrollbar remains active alongside the horizontal one. Both scrollbars are styled to match via CSS custom properties and WebKit pseudo-element rules.
Custom Scrollbars
The grid replaces native scrollbars with JavaScript-driven custom scrollbars (vertical and horizontal):
- Smooth thumb drag with a position indicator showing the current row range.
- Expand on hover/drag; collapse when idle.
- Styled via
.vg-scrollbar-track,.vg-scrollbar-thumb, and.vg-scroll-indicatorCSS classes — override in your theme file. - Vertical custom scrollbar can be disabled per instance:
layout: { customScrollbar: false }. - The horizontal scrollbar is always custom when horizontal scroll is needed.
Column Reordering
Users can rearrange columns by dragging a header to a new position. A drop indicator line shows the insertion point.
Mouse Drag
Click and hold a header cell, then drag left or right. Release to drop.
Touch Reorder
On touch devices, press and hold a header for touchReorderHoldDelay milliseconds (default: 220 ms) to activate reorder mode, then drag.
Disable touch reorder with columns: { touchReorder: false }.
Frozen Column Boundary Enforcement
Frozen and unfrozen columns form separate groups. You can reorder within a group freely, but dragging across the frozen/unfrozen boundary is blocked. To move a column across the boundary, use the context menu to freeze or unfreeze it first.
Persistence
Column order is saved to localStorage automatically when persistence.columnOrder.enabled is true (default). The key is derived from the table/viewport/header element IDs and the page path, or can be overridden with persistence.columnOrder.storageKey.
Programmatic Reordering
grid.reorderColumn(fromIndex, toIndex, { dropBefore: true });
Column Visibility
Individual columns can be hidden and shown again without removing them from the column definition.
Context Menu
Right-click any column header to open the context menu. Select Hide column to hide the current column. If any columns are hidden, a Show all columns option appears to restore them all.
API
grid.hideColumn('weight'); // Hide by key
grid.showColumn('weight'); // Restore by key
grid.showAllColumns(); // Restore all hidden columns
grid.getHiddenColumns(); // ['weight', ...]
grid.canHideColumn('name'); // false when it is the last visible column
Behavior
- At least one data column is always visible — the last visible column cannot be hidden.
- Hiding a frozen column also unfreezes it automatically.
- The selection checkbox column is never hidden.
Persistence
Hidden column state is saved to localStorage when persistence.hiddenColumns.enabled is true (default). Override the key with persistence.hiddenColumns.storageKey.
Localization
messages: {
hideColumn: 'Hide column',
showAllColumns: 'Show all columns'
}
Column Freeze / Unfreeze
Frozen columns stay anchored at the left edge of the viewport and never scroll out of view horizontally. A blue vertical guide line marks the freeze boundary.
Context Menu
Right-click a column header to open the context menu. Select Freeze column to freeze it, Unfreeze column to release it, or Unfreeze all when multiple columns are frozen.
API
grid.freezeColumn('name'); // Freeze by key
grid.unfreezeColumn('name'); // Unfreeze by key
grid.unfreezeAll(); // Release all frozen columns
grid.isFrozenColumn('name'); // true / false
grid.getFrozenColumns(); // ['name', ...]
Behavior
- Frozen columns are always moved to the front (left side) of the column order, after the selection checkbox column if present.
- Multiple columns can be frozen simultaneously.
- The selection checkbox column is always implicitly frozen — it is never included in
getFrozenColumns(). - Frozen header cells use a counter-
translateXto cancel the header table's scroll shift, keeping them visually pinned. - Body frozen cells use
position: stickywith computedleftoffsets.
Persistence
Frozen column state is saved to localStorage when persistence.frozenColumns.enabled is true (default). Override the key with persistence.frozenColumns.storageKey.
CSS Custom Properties
| Custom Property | Description |
|---|---|
--vg-frozen-bg |
Background fill of frozen cells (prevents see-through during horizontal scroll) |
--vg-freeze-guide-color |
Color of the vertical freeze boundary line |
Localization
messages: {
freezeColumn: 'Freeze column',
unfreezeColumn: 'Unfreeze column',
unfreezeAll: 'Unfreeze all'
}
Multi-Column Sorting
- Click a header to cycle sort for that column:
none → asc → desc → none. - Shift+Click adds/removes a column in the active multi-sort chain.
- Sort priority follows the order in which columns are added to the chain.
Context Menu
Right-click a sortable column header to open the context menu. Select Sort ascending or Sort descending to sort by that column, or Clear sorting to remove just that column from the sort chain — each item disables itself when it no longer applies (e.g. Sort ascending is disabled once the column is already sorted ascending). These items only touch the clicked column's place in the sort chain, leaving any other column's sort untouched; they are omitted entirely for a column with sortable: false or when sorting.enabled is false.
// getSortState() return shape
{
columnIndex: 1, // Primary sort column index
direction: 'asc', // Primary sort direction
column: { ... }, // Primary sort column object
sortColumns: [ // Full multi-sort chain
{ columnIndex: 1, direction: 'asc', column: { key: 'name', ... } },
{ columnIndex: 4, direction: 'desc', column: { key: 'weight', ... } }
]
}
For server-side / OData sorting:
const grid = new VanillaGrid({
sorting: {
serverSide: true,
onSort: (_column, _direction, sortState) => {
const orderBy = (sortState.sortColumns || [])
.map(entry => `${entry.column.key} ${entry.direction}`)
.join(', ');
// Re-fetch with the new $orderby
reloadWithOrderBy(orderBy);
}
}
});
Localization
messages: {
sortAscending: 'Sort ascending',
sortDescending: 'Sort descending',
clearColumnSort: 'Clear sorting'
}
Row Selection
Modes
| Mode | Behavior |
|---|---|
'noselection' |
No selection UI (default) |
'single' |
One row at a time; clicking a selected row deselects it |
'multiple' |
Any number of rows; header checkbox selects/deselects all |
const grid = new VanillaGrid({
selection: {
mode: 'multiple',
rowKeyField: 'id', // Field used as stable key
showCheckboxes: true
}
});
Key Strategy (Precedence)
rowKeyGetter(row)— custom function, takes priority- Column marked with
isKeyColumn: truein column definitions rowKeyFieldoption (default'id')- Auto-generated UUID per row (fallback; a console warning is emitted)
Selection API
grid.getSelectedKeys(); // ['1', '42', ...]
grid.getSelectedRows(); // [{ id: 1, ... }, { id: 42, ... }]
grid.setSelectedKeys(['1', '2']); // Replace current selection
grid.clearSelection();
grid.isRowSelected(rowObject); // true / false
grid.selectionState; // 'none' | 'partial' | 'all'
grid.selectAllActive; // true when sticky select-all is active
selectionChanged Event
Dispatched on the viewport element (bubbles):
viewport.addEventListener('selectionChanged', (e) => {
const {
mode, // 'single' | 'multiple'
selectionState, // 'none' | 'partial' | 'all'
selectAllActive, // boolean
selectedKeys, // string[]
selectedCount, // number
selectedRows // object[]
} = e.detail;
});
Note:
selectedKeysandselectedRowsare lazy compute-once getters — the arrays are materialized on first access, so listeners that never read them cost nothing even with select-all active on large datasets. Read them within the event dispatch (as above) for values that match the change being reported.
rowDblClick Event
Dispatched on the viewport element (bubbles) when a row is double-clicked. Selection is not toggled by a double-click — the row keeps its current selected/unselected state.
Double-click detection uses pointerdown timing instead of the native dblclick event, ensuring reliable firing even when custom renderCell content (e.g. thermometer bars) replaces cell DOM between clicks.
viewport.addEventListener('rowDblClick', (e) => {
const {
row, // object — the row data
rowIndex // number — index in displayRows
} = e.detail;
});
On the <vn-grid> web component, this is bridged as vn-grid-row-dblclick:
document.querySelector('vn-grid').addEventListener('vn-grid-row-dblclick', (e) => {
console.log('Double-clicked row:', e.detail.row);
});
Infinite Scroll + Select All
When all currently-loaded rows are selected (selectionState === 'all'), VanillaGrid sets a sticky select-all flag (selectAllActive). Any rows appended by subsequent infinite-scroll pages are automatically selected too.
Deselecting a single row in this state clears the sticky flag.
DataManager
The DataManager pattern decouples data fetching from grid rendering. Attach a DataManager to a <vn-grid> element with setDataManager().
Base DataManager API
Subclass DataManager and override any of these async methods:
| Method | Signature | Description |
|---|---|---|
fetchRows(context) |
async (ctx) => row[] |
Fetch the first page of rows |
fetchMoreRows(skip, top, context) |
async (skip, top, ctx) => row[] |
Fetch a subsequent page |
getPageSize() |
() => number |
Page size (default 1000) |
buildRequestHeaders(context) |
(ctx) => object |
HTTP headers for default fetches |
transformRows(rows, context) |
(rows, ctx) => rows |
Post-process rows before render |
handleSort(column, direction, sortState) |
(col, dir, state) => void |
Update the sort clause; fire _fireConfigChanged() so a grid with setAutoReloadOnConfigChange(true) re-fetches page 0 on sort |
onRowsLoaded(rows, context) |
(rows, ctx) => void |
Side-effect after rows load |
Total row count is a push, not a method to override: call
this._fireTotalRowCountChanged(count) from within fetchRows() (or wherever
your subclass determines the total for its current query) — see
Total Row Count.
ODataDataManager
A full-featured DataManager for OData endpoints.
const dm = new ODataDataManager({
baseUrl: 'http://localhost:3000/api/items',
countUrl: 'http://localhost:3000/api/items/$count',
headers: { 'X-Custom-Header': 'value' },
defaultOrderBy: 'name asc',
defaultFilter: "active eq true",
pageSize: 500,
onSortChanged: (orderBy) => console.log('Sort:', orderBy),
onRowsLoaded: (rows, ctx) => console.log('Loaded', rows.length),
onFetchResponse: (response) => { /* inspect raw Response (headers, status) */ },
onBeforeFetch: () => { /* e.g. clear a status line */ },
onFetchError: (error) => console.error(error)
});
gridElement.setDataManager(dm);
gridElement.initializeGrid({
infiniteScroll: { enabled: true },
sorting: { serverSide: true },
onTotalRowCountChanged: (count) => console.log('Total:', count)
});
gridElement.setAutoReloadOnConfigChange(true); // re-fetch page 0 on sort/filter/search
await gridElement.loadRowsAsync();
Server-side sort re-fetch:
handleSort()updates$orderbyand fires a config-change, so withsetAutoReloadOnConfigChange(true)a sort re-fetches page 0 automatically — noonSortChangedreload callback needed (it is a notification-only hook). Without auto-reload, reload yourself (e.g. callloadRowsAsync()fromonSortChanged).
Loading state is automatic:
loadRowsAsync()brackets every load — the first one included — withsetLoading(true/false)itself (error path included, guarded against superseded concurrent loads). Do not wiresetLoadingintoonBeforeFetch/onFetchResponse/onFetchError; those are pure app hooks (status text, raw-Responseinspection, error UI).onBeforeFetch/onFetchResponsefire once per user-visible load (fetchRows()), never per infinite-scroll load-more page (which has its own inline skeleton);onFetchErrorfires for load-more failures too.
Runtime OData methods:
dm.getBaseUrl() / dm.setBaseUrl(url)
dm.getCountUrl() / dm.setCountUrl(url)
dm.getOrderBy() / dm.setOrderBy(orderBy)
dm.getFilter() / dm.setFilter(filter)
dm.getSearchTerm() / dm.setSearchTerm(term) // $search (default) or $filter contains()
dm.getSearchMode() / dm.setSearchMode(mode) // 'search' | 'filter'
dm.getSearchFields() / dm.setSearchFields(fields)
Construct with searchMode: 'search' | 'filter' (default 'search') and, for
'filter' mode, searchFields: ['Name', 'City']. getSearchFields() returns
null (unrestricted) unless searchMode: 'filter' is active and the list is
non-empty — in 'search' mode the server owns $search's field scope, so the
configured list is inert. See
Generic Search.
GraphQLDataManager
A DataManager for GraphQL endpoints. GraphQL standardizes only transport and
envelope (POST { query, variables } → 200 OK { data?, errors? } — a GraphQL
error is an HTTP 200 with a populated errors array, not a 4xx/5xx), so this
manager owns everything universal (transport, envelope/errors handling,
cancellation, last-wins version guarding, total-count push, config-changed
events, response unwrapping via a declared dot-path) while the host supplies the
query document plus small builder hooks that translate grid state into
the schema's variables. Two-tier config: declare variableNames for the
common case, or pass buildVariables (which then fully owns assembly) plus the
per-slice buildSort / buildFilter / buildSearch hooks. Supports both offset
paging and Relay cursor paging (paginationStyle: 'offset' | 'cursor'), and a
total from either an inline totalPath or a separate countQuery.
const dm = new GraphQLDataManager({
endpoint: 'https://graphql.anilist.co',
query: ANILIST_QUERY,
paginationStyle: 'offset',
pageSize: 50,
infiniteScroll: true,
dataPath: 'Page.media', // where the rows array lives in `data`
totalPath: 'Page.pageInfo.total', // inline total — no second request
buildVariables: (state) => ({
page: state.page, perPage: state.perPage,
search: state.search, sort: state.orderBy, ...state.filter,
}),
buildSort: (column, direction) => [mapToMediaSort(column, direction)],
buildFilter: (model) => mapToAniListArgs(model),
onFetchError: (error) => console.error(error),
onGraphQLErrors: (errors, data) => { /* default throws GraphQLDataManager.GraphQLError */ },
});
gridElement.setDataManager(dm);
gridElement.initializeGrid({
infiniteScroll: { enabled: true },
sorting: { serverSide: true },
onTotalRowCountChanged: (count) => console.log('Total:', count)
});
await gridElement.loadRowsAsync();
Runtime methods: getEndpoint() / setEndpoint(url), getQuery() /
setQuery(query), getSearchTerm() / setSearchTerm(term),
getColumnFilters() / setColumnFilters(model), isInfiniteScroll() /
setInfiniteScroll(enabled), getMaxRows() / setMaxRows(n).
A populated errors array throws GraphQLDataManager.GraphQLError (carrying
.graphQLErrors) by default — even alongside partial data; a non-throwing
onGraphQLErrors override tolerates partial data. In cursor mode the manager
unwraps a Relay connection's edges[].node and reads pageInfo automatically.
See GraphQLDataManager Internals
and anilist-anime-js (AniList) for a working demo.
StaticDataManager
For pre-loaded in-memory datasets with client-side paging.
const dm = new StaticDataManager({
rows: myDataArray,
pageSize: 500
});
gridElement.setDataManager(dm);
gridElement.initializeGrid({ infiniteScroll: { enabled: true } });
await gridElement.loadRowsAsync();
Runtime methods: setRows(rows), getRows(), getPageSize(), setSearchTerm(term) / getSearchTerm(), setSearchFields(fields) / getSearchFields(), prewarmSearchIndex()
Pre-warming search (large datasets). On a large dataset the first search pays a one-time haystack extraction (later searches are instant). prewarmSearchIndex() builds that index ahead of time so the first search is a cache hit too. It's best-effort and idempotent, and nothing warms unless you ask it to.
The simplest way is to let the manager schedule it, via the prewarmSearchIndex option — right next to searchFields, which defines what the index covers:
const dm = new StaticDataManager({
rows,
searchFields: ['firstName', 'lastName', 'email'],
prewarmSearchIndex: true // default false
});
When on, the manager warms the index at idle after each row ingestion (construction with rows, and every setRows()), so the build lands after first paint and never competes with the initial render — the extraction is chunked and rAF-yielded, so running it during a render would steal frames from it. A bounded timeout means "at idle" can never become "never". Re-warming is cheap: the pre-warm resolves immediately while the index is resident, so only a genuine data or searchFields change costs anything.
Off by default — warming spends CPU and memory up front that is wasted on users who never search, so nothing happens unless you ask. It also only pays off at scale: below workerThreshold (50,000 rows) the build runs on the main thread and a first search over that many rows is fast anyway, so leaving it off is usually right for small datasets.
This is a StaticDataManager option, not a grid one — a server-backed manager searches remotely and has no local index to warm — so it also works for a manager used standalone, with no <vn-grid> attached. To control the timing yourself instead, leave it off and call the method directly:
gridEl.addEventListener('vn-grid-loaded', () => {
requestIdleCallback(() => gridEl.getDataManager().prewarmSearchIndex());
});
Measuring the build (dev mode). Set window.VanillaGridDevMode = true — before the grid loads — and StaticDataManager emits console.info messages on the VanillaGrid: channel when it builds the search index:
VanillaGrid: search index build started — 500,000 rows × all fields (worker)
VanillaGrid: search index built in 5,182 ms — 500,000 rows × all fields (worker)
Each line names the row count, the searched fields and the path (worker or in-thread) — the three things that determine the cost — so you can confirm the pre-warm really starts early and see what scoping searchFields saves. A "started" line is only emitted when a build actually begins, never on an already-resident no-op, and a failed build reports its own outcome rather than leaving a dangling start line. The flag is off by default and there is no build-time dev/prod split, so production stays silent unless a host opts in. Only the pre-warm is instrumented — an index built lazily by the first search is not.
Search matches a case-insensitive substring against the raw field values.
By default every scalar field is searched; pass searchFields: ['name', 'address.city']
(dotted paths allowed) to the constructor — or call setSearchFields(...) — to
restrict the scope. Trigger from the element with await gridElement.search(term).
getSearchFields() returns null when unrestricted — consumed by
<vn-grid-toolbar-search> to show a fields-restricted tooltip, and by
gridElement.getSearchFields().
Memory & performance on large datasets. For very large in-memory datasets (hundreds of thousands to millions of rows), two opt-in levers reduce client-side memory and first-search time without any cross-origin isolation:
Set
searchFields. All-fields search keeps a lowercased copy of every field of every row resident (and pays a large one-time build). Scoping to the columns users actually search cuts both roughly in proportion. When a search runs over a large dataset (≥workerThreshold) withsearchFieldsunset, the manager logs a one-shotconsole.warnadvising you to set it.internStringColumns: ['department', 'city', …]deduplicates those low-cardinality columns' string values on ingestion so equal cells share one instance — reclaiming significant memory with no data-model change. Top-level fields only. Interns in place by default (the manager owns the rows it's given); passcopyOnIntern: trueto intern into shallow copies instead.Pick columns that meet both conditions, or the pass costs time and reclaims nothing: the column must be low-cardinality (few distinct values) and must hold a distinct string instance per row. The second condition is the one that gets missed. It holds whenever rows are produced by a parser or by per-row computation —
JSON.parse()of a fetched payload allocates a fresh string for every cell, which is the normal case for aStaticDataManagerfed from a server, and the case this option is for. It does not hold when rows are built in-page by assigning values out of a shared lookup array or from string literals: those cells already point at one shared instance, so there is nothing left to dedup. Interning effectively-unique columns (IDs, emails, free text) is likewise wasted work — it builds a pool entry per row and never gets a hit.Measured on 500,000 rows of the
grid-minimal-jsshape, arriving viaJSON.parse(): 207.8 MB retained with no interning, 124.8 MB with the seven low-cardinality string columns interned (a 40% reduction, one ~270 ms pass). The identical rows synthesized in-page from literal arrays instead show zero reduction from the same option, because they were already sharing instances.
const dm = new StaticDataManager({
// Rows parsed from a server payload: every cell is its own string
// instance, so the low-cardinality columns below dedup heavily.
rows: await response.json(),
searchFields: ['name', 'email', 'city'],
internStringColumns: ['department', 'city', 'country', 'role'],
});
Viewport Row Snapping
When layout.snapViewportToRows is enabled (default), VanillaGrid snaps the viewport body height to the nearest lower multiple of the effective row height. This avoids partial-row viewport artifacts such as bottom-border misalignment and clipped-looking last rows.
- Recommended for fixed-row-height virtualized grids.
- Reapplies automatically on initial render, resize, and row-height remeasurement.
- Set
layout: { snapViewportToRows: false }if you need unconstrained full-height fill.
Resize Scroll Stabilization
VanillaGrid includes guards to prevent resize-induced scroll feedback loops and accidental load cascades when the viewport height changes.
What this protects against:
- Continuous or jittery scrolling right after browser/container resize.
- Infinite-scroll prefetch loops triggered by resize-only geometry changes.
- Incorrect momentum-guard corrections caused by non-user
scrollTopupdates during resize.
Current behavior:
- A short resize phase is tracked internally while the viewport settles.
- During that phase, virtualization still re-renders, but uncontrolled-scroll rejection is bypassed.
- Infinite-scroll prefetch is paused until resize settle completes.
- When resize lands near the top, the viewport is normalized to true top (
scrollTop = 0) so row1remains the first visible row.
Related tuning knobs: layout.rowHeight, layout.bufferRows, layout.snapViewportToRows.
Localization Hooks
VanillaGrid stays locale-agnostic by default and exposes formatting hooks so the host app can provide localization logic:
formatting.locale— used by defaultIntl.NumberFormatbehavior.formatting.messages— all built-in UI labels (context menu items, scroll indicator separator).formatting.formatInteger(value)— formats row counters.formatting.formatScrollIndicator(context)— fully custom indicator text formatter.context.firstRow/context.lastRoware positions in the full row order; with row grouping active a collapsed group's rows count as passed once its caption is passed, so the pair spans the range of the dataset the viewport represents rather than the number of rows physically on screen.onTotalRowCountChanged(count)— optional top-level callback fired whenever the total row count changes, to sync host-app status text.
Example:
const grid = new VanillaGrid({
formatting: {
locale: 'it-IT',
messages: {
of: 'di',
hideColumn: 'Nascondi colonna',
showAllColumns: 'Mostra tutte le colonne',
freezeColumn: 'Blocca colonna',
unfreezeColumn: 'Sblocca colonna',
unfreezeAll: 'Sblocca tutto',
sortAscending: 'Ordina crescente',
sortDescending: 'Ordina decrescente',
clearColumnSort: 'Rimuovi ordinamento'
},
formatInteger: (value) => new Intl.NumberFormat('it-IT').format(value),
formatScrollIndicator: ({ firstRow, lastRow, displayedRowCount, totalRowCount, formatInteger, messages }) => {
const denominator = Number.isFinite(totalRowCount) ? totalRowCount : displayedRowCount;
return `${formatInteger(firstRow)} - ${formatInteger(lastRow)} ${messages.of} ${formatInteger(denominator)}`;
}
},
infiniteScroll: {
enabled: true
},
onTotalRowCountChanged: (count) => console.log('Known total:', count)
});
Keyboard Navigation
When the viewport is focused or hovered, the following keys scroll the grid:
| Key | Action |
|---|---|
ArrowUp / ArrowDown |
Scroll one row |
PageUp / PageDown |
Scroll one viewport height |
Space / Shift+Space |
Scroll down/up one viewport height |
Home / End |
Jump to first/last row |
Themes
The component includes eight pre-built themes in themes/:
| File | Description |
|---|---|
themes/vn-grid-default.css |
Clean, minimal theme |
themes/vn-grid-material.css |
Google Material |
themes/vn-grid-fiori.css |
SAP Fiori-inspired theme |
themes/vn-grid-carbon.css |
IBM Carbon Design System (light) |
themes/vn-grid-carbon-dark.css |
IBM Carbon Design System (dark) |
themes/vn-grid-glow.css |
Glow UI (light) |
themes/vn-grid-glow-dark.css |
Glow UI (dark) |
themes/vn-grid-fluent.css |
Microsoft Fluent 2 |
themes/TEMPLATE-vn-grid-theme.css |
Blank template for custom themes |
Switching Themes
Via the web component (it swaps the injected theme stylesheet itself):
gridElement.setTheme('carbon-dark');
Creating Custom Themes
Copy themes/TEMPLATE-vn-grid-theme.css, override the CSS custom properties listed in the template, and register it with VanillaGridElement.registerTheme('mytheme', 'path/to/vn-grid-mytheme.css').
Sort Icons (required per-theme tokens)
The sort glyphs are theme tokens with no base default — every theme
declares its own full set on .vn-grid-header-table th (a theme that omits
them renders no sort glyphs at all):
.vn-grid-header-table th {
--vn-grid-sort-icon-asc: '▲'; /* ascending glyph */
--vn-grid-sort-icon-desc: '▼'; /* descending glyph */
--vn-grid-sort-icon-sortable: none; /* unsorted "sortable" affordance; none = off */
--vn-grid-sort-icon-sortable-opacity: 0; /* affordance resting opacity */
--vn-grid-sort-icon-sortable-hover-opacity: 0; /* …while the header is hovered */
}
The affordance supports three modes per theme: off (none), hover-only
(opacity 0 → 1 — the Carbon themes show a hover-only ⇅ with ↑/↓
direction glyphs; the Material theme shows an alphabetical "AZ" SVG glyph via
content: url("data:image/svg+xml,…") — both triangles on hover, a single
accent-colored triangle when sorted asc/desc, colors baked into the SVGs
since url() images ignore currentColor), or always visible (resting
opacity > 0). See
15-themes-implementation.md §4.5.1
for the full matrix and the migration note for pre-existing custom themes.
Header parts
The header label is one row: .vn-grid-header-text (with its sort
indicator), then .vn-grid-header-secondary-text (e.g. [kg]), then, on an
aggregated column, .vn-grid-aggregate-badge. In a narrow column the label
ellipsizes first, then the secondary text; the badge never shrinks. The badge
reads five optional tokens, each with a fallback:
.vn-grid-table-container {
--vn-grid-aggregate-badge-color: inherit;
--vn-grid-aggregate-badge-bg: color-mix(in srgb, var(--vn-grid-filter-accent) 16%, transparent);
--vn-grid-aggregate-badge-radius: 999px;
--vn-grid-aggregate-badge-padding-x: 5px;
--vn-grid-aggregate-badge-font-size: 0.8em;
}
The badge and each group footer cell show the function as an icon: a masked
SVG painted in the text colour, one token per built-in function. The defaults
live in vanilla-grid.css; a theme overrides any of them with a 24×24,
round-capped, stroke-width='2.5' SVG:
.vn-grid-table-container {
--vn-grid-aggregate-icon-sum: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M18 4H6l7 8-7 8h12'/></svg>");
/* also -avg, -median, -min, -max, -countTrue, -countDistinct */
--vn-grid-aggregate-icon-size: 1em;
}
See 15-themes-implementation.md §4.8.
Implementation Docs
For implementation-level details, see the technical docs. They are listed in the same reading order as 00-index.md's Documents section — foundations first, then the features that build on them.
Core engine
Data access
Sorting, filtering, and column types
- 06-sorting-implementation.md
- 07-column-filters-implementation.md
- 08-date-time-types-implementation.md
Row interaction
Column management
- 11-column-resizing-implementation.md
- 12-columns-reordering-implementation.md
- 13-column-visibility-implementation.md
- 14-column-freezing-implementation.md
Presentation
Export
Persistence, delivery, and performance
- 19-local-storage-settings-implementation.md
- 20-cache-bust-implementation.md
- 21-performance-analysis.md
Row grouping
Browser Support
- Chrome / Edge: Latest 2 versions
- Firefox: Latest 2 versions
- Safari: Latest 2 versions
Performance
- Handles 100,000+ rows smoothly with recycled row pool virtual scrolling
- Real-time column resize feedback via explicit pixel-width
table-layout: fixed - GPU-accelerated header horizontal sync (
transform: translateX) — no reflow on scroll - DocumentFragment-based pool initialization — single DOM insertion per rebuild
- RequestAnimationFrame-based scroll rendering with threshold-based short-circuit
- Infinite-scroll prefetch at 30% of page size remaining — preloads the next page without waiting for the user to reach the absolute end
- O(1) row key lookups via internal
Mapfor selection state
License
MIT
Contributing
Contributions are welcome! Please ensure:
- Code follows existing style
- All features are documented
- Performance is maintained
Changelog
Version 1.40.0
- Changed (
vanilla-grid) — breaking forexportToExcel()callers usingformat,xlsxWorkerUrlor SheetJS globals: the Excel export no longer uses SheetJS. The grid writes the.xlsxitself, streaming rows in short chunks into a writer that compresses the sheet XML with the browser's nativeCompressionStream('deflate-raw'), in the warm export Worker fromworkerThresholdrows and on the main thread (same writer, still chunked) below it or withuseWorker: false.- Why. A large export froze the page and then failed. On
grid-minimal-jswith 21 columns, SheetJS hitRangeError: Invalid array lengthabove about 140,000 rows (its ZIP output went through a JS array with one element per byte), and the coordinator then retried the whole export synchronously on the main thread, freezing the tab for about 100 s before failing the same way. At 300,000 rows the tab crashed. Cell styling was not the cause: an unstyled export failed identically. - Result, same machine and dataset: 100,000 rows in about 3 s instead
of 40 s, 14 MB instead of 92 MB, about 500 MB peak memory instead of
1.9 GB, and no main-thread task over 50 ms. 1,000,000 rows now export in
about 30 s. Rows past Excel's 1,048,575-row sheet limit continue on
sheets named
'<sheetName> (2)', …, each with its own header, frozen pane and AutoFilter. - No main-thread retry. A Worker that ran and failed now rejects at once instead of repeating the export synchronously.
- Coded failures.
exportToExcel()rejects with aVanillaGridExportError(new invanilla-grid.d.ts) whosecodeisEXPORT_FAILED(causeholds the reason),EXPORT_TOO_LARGE(a sheet would pass the ZIP format's 4 GiB entry limit, only reachable with about 90+ columns at a full sheet; carriesrows,columns),EXPORT_CANCELLED, orEXPORT_IN_PROGRESS(a second call while one runs on the same grid). A failure turns the overlay into analertdialogthat says why, with a Close button (Escape closes it) instead of silently disappearing;overlayThreshold: Infinityleaves the rejection to the host. - Progress and Cancel. The overlay shows
Exporting to Excel… {n}%and a Cancel button on both the Worker and main-thread paths; the Worker stays warm for the next export. - iOS. When
navigator.share()rejects withNotAllowedErrorbecause the user activation expired during a long export, the overlay offers a Save file button instead of doing nothing. Not yet verified on a device. - Fixed along the way. Date and datetime columns now carry the
documented
yyyy-mm-dd/yyyy-mm-dd hh:mm:ssformats; SheetJS silently replaced them with the locale's short date, dropping the time. Cell values and date serials are unchanged. - Removed. The
formatoption ('csv'and'ods'existed only through SheetJS): a leftoverformatother than'xlsx'exports.xlsxand logs one warning. Also removed:xlsxWorkerUrl,window.VanillaGridXlsxURL,window.VanillaGridBaseURL, theXLSXglobal,features/xlsx-js-style.bundle.min.jsand theexportRequiresXlsxmessage. Every page load sheds about 425 KB (142 KB gzipped) of script, and the components contain no third-party code. Migration: dropformat/xlsxWorkerUrl; code that readwindow.XLSXmust bring its own copy. styleHeaderCell/styleDataCelland the static style options keep the same object shape (VanillaGridExportCellStyle); only the documented subset (font,fill,border,alignment,numFmt) is honoured.- New message keys:
exportingToExcelProgress,exportCancel,exportClose,exportTooLarge,exportFailed,exportSaveFile. - A host that registers its own
exportToExceltoolbar command should return the export's promise, so the toolbar can ignore a cancel and log real failures; every sample now does. build.js:features/excel-export.feature.jsjoinsBUNDLE_FAST_PATH(its per-cell loop is a hot path); the SheetJS copy and bundle steps are gone. See Export to Excel.
- Why. A large export froze the page and then failed. On
- Maintenance (
vanilla-grid, sample apps,eslint.config.js,package.json,.claude/agent commands): a unit test now pins the aggregate walk to one visit per row; the README's cost figure for recomputing aggregates per selection was corrected against current measurements; the performance analysis was updated for the export rewrite; the SheetJS lint globals and coverage exclusion were removed; every sample gained the new export message keys; shipped feature specs were cleaned out ofspecs/.
Version 1.39.1
- Fixed (
vanilla-grid): the vertical scrollbar thumb now re-sizes immediately afterexpandAllGroups(),collapseAllGroups(),expandGroup()/collapseGroup(), a caption toggle, and asetGroupState()that re-nests the same levels (e.g.Country ▸ City→City ▸ Country), instead of keeping the previous projection's size until the next scroll. The vertical track hides or reappears in the same pass when the collapsed groups fit in the viewport, or stop fitting after an expand. A thumb drag started right after one of these gestures no longer jumps the grid in the wrong direction. - Maintenance:
package.jsonversion bumped to 1.8.1.
Version 1.39.0
- Added (
vanilla-grid): group aggregates — a named reduction over one column's values, rendered into a group footer row under each group at each level. Seven functions ship built in, offered by the column's declared type:sum,avg,median,min,maxonnumber;min/maxalso ondate/datetime/time(reporting the winning row's raw value);countTrueonboolean(throughbooleanCoerce);countDistincton every type butboolean, counting with grouping's equality. Hovering an aggregated footer cell names the function in full ("Average of Projects: 14.165"). Nothing renders until a column carries one: a grouped grid with no aggregate is exactly the grid it was.- Configuration.
grouping.aggregates: [{ key, fn }]declaratively (a default, not a lock — the user's choice persists and wins afterwards, andclearPersistedSettings()restores the declaration);setAggregates(state)at runtime, returning{ available, reason }over a closed reason-code set; or the header menu's new "Aggregate ▸" submenu, present on every column's menu whatever the grid state and disabled with an explainedtitlewhen it cannot act.fnis always a registered reducer name, so the configured set is plain serializable data in every direction. - Reading it back.
getAggregates(),getGroupAggregates(path)(one group's computed values by column key, takingexpandGroup()'s path shape), andcanAggregateColumn(key)— an O(1), row-scan-free predicate returning{ available, reason, functions }. Newvn-grid-aggregates-changedevent, fired once per settled change; a superseded recomputation emits nothing of its own — the set it installed is announced by whichever operation settles. - Extending it. Static
VanillaGrid.registerAggregate(name, reducer)takes the same{ init(context), step, finalize, types }shape the built-ins have, so a host reducer passes the samecolumn.typeeligibility gate, plus optionalprepare(context)(a per-row mapper factory) andresultType: 'count'. New.d.tstypesVanillaGridAggregateReducerandVanillaGridAggregateContext;VanillaGridAggregateValuesvalues areunknown. - Lifetime. An aggregate belongs to the grouping session: it is
discarded when the last group level goes, retained (but not editable)
through a partial-dataset suspension, and pruned when
setColumns()removes, renames or retypes its column — each settling persisted and announced with exactly one event. It persists inside the existingpersistence.groupStateflag: levels and aggregates share one key and one lifetime, and a value stored before aggregates existed reads back as none configured with no migration. - An aggregate and a group level never share a column.
setAggregates()refuses one the grid is grouped by ('column-grouped'), and grouping by an aggregated column prunes that aggregate. - Auto-fit sizes an aggregated column from its computed totals rather than from whichever footer rows are materialized, so the fitted width is the same from any scroll position.
- Eight new
--vn-grid-group-footer-*tokens across all eight themes, the theme template and the sample'svn-grid-apple.css; new localizableaggregateFunctionLabels/aggregateFunctionMarkersmaps and sixaggregate*message scalars. Newfeatures/header-menu-submenu.feature.jsprovides a general submenu mechanism the menu's other entries can reuse. - Header badge. While a column's footer cells show a total, its header
shows the footer's marker as a pill after the secondary header text
(
.vn-grid-aggregate-badge, withdata-fn), and theth'stitlenames the function. The footer is often scrolled out of view, and the header never is. It follows every settled change to the set, including the ones nosetAggregates()call made, hides while grouping is suspended, and updates in place without rebuilding the header. Not interactive: a click is a header click. Five new--vn-grid-aggregate-badge-*tokens, all with fallbacks, set by all eight themes, the template andvn-grid-apple.css. - Marker icons. A built-in function's marker, in the footer and the
badge, is a masked SVG (
.vn-grid-aggregate-icon,data-fn) drawn from seven--vn-grid-aggregate-icon-*tokens plus--vn-grid-aggregate-icon-size, declared invanilla-grid.cssand overridable per theme; font glyphs came out too heavy or off-centre in some themes. Visible in forced-colors mode.messages.aggregateFunctionMarkersis now empty by default: an entry replaces the icon with text, which is also how a registered function gets a marker, and''still hides it. - Auto-fit fits a string aggregate result (e.g. a registered most-common-value function) to the longest result, not the first group's.
grid-minimal-jsships Sum of Projects and Average of Height over itsCountry ▸ Citygrouping, and registers two custom functions,stdev(σ) andmode(Mo), as workingregisterAggregate()examples.- Not in v1:
count, a grand total, group footers in the Excel export, server-side aggregation, cross-column formulas, per-column function sets, and selection-scoped values. See the feature's section above. - See docs/vanilla-grid/22-grouping-implementation.md §5.2 and docs/vanilla-grid/19-local-storage-settings-implementation.md §6.7.
- Configuration.
- Changed (
vanilla-grid) — breaking for custom themes and for columns usingunit: the text after a header label is called secondary header text everywhere, as the public API already called it (column.secondaryLabel,formatting.getHeaderSecondaryText).- The CSS class
.vn-grid-unit-labelis renamed.vn-grid-header-secondary-text, with no alias. Migration: rename the selector in any custom theme. - The undocumented
column.unitalias forcolumn.secondaryLabelis gone, from the defaultgetHeaderSecondaryTextand from the Excel export's header.<vn-grid-column unit="…">is now an ignored attribute. Migration: usesecondaryLabel/secondary-label. - In a narrow column the secondary text now ellipsizes instead of running on under the filter funnel.
- The CSS class
- Changed (
vanilla-grid) — breaking for custom themes that set the group toggle tokens: the group caption's expand/collapse marker is now a masked SVG painted incurrentColor(like the aggregate marker icons) instead of a▾/▸font glyph, so its weight and centring no longer depend on the theme's font — it sat small and low in Carbon dark.--vn-grid-group-toggle-icon-expanded/-collapsednow take an SVGurl()used as a mask, defaulting to a down / right chevron; a string value renders no marker.--vn-grid-group-toggle-icon-sizeis now the icon box's width and height (same1emdefault). The collapsed icon is mirrored in RTL, and the marker paintsButtonTextin forced-colors mode. The default and Glow themes show filled triangles and the Carbon themes a boxed minus / plus (at1.2em) instead of chevrons. Migration: set the two tokens to aurl("data:image/svg+xml,…"), or delete the declarations to get the default chevrons. - Added (
vanilla-grid): fold or unfold the groups below a caption in one step. Shift-click or Shift+Enter on a caption with child groups opens it and switches everything below it between all folded and all open; the first Shift-click on a caption shows it as a summary of its folded child groups. From code,expandGroup(path, { descendants })/collapseGroup(path, { descendants })set a group and its whole subtree in any combination, all or nothing (refused past the cardinality ceiling). Onevn-grid-group-expanded/-collapsedper call, whosedetailgainsdescendants('collapsed'/'expanded',nullon every other event). New messagegroupCaptionToggleHint(the toggle's hover hint), andaria-keyshortcuts="Shift+Enter"on toggles that offer it. - Fixed (
vanilla-grid): hovering a group caption showed a tooltip repeating its own label ("City: Amsterdam — 5 items"). The overflow-tooltip pass took the label, which deliberately runs past its cell, for truncated text. Caption cells no longer get one. - Fixed (
vanilla-grid): thesecondary-labelattribute on<vn-grid-column>was ignored. It now maps tosecondaryLabel, soanilist-anime-js's Duration[min]and Score[0–100]headers, and their Excel export, show their secondary text. - Fixed (
vanilla-grid): auto-fit sized a header for the wider of its label and its secondary text, as if they sat on separate lines. They share one row, so every column with secondary text was fitted too narrow ("Temperature [°C]" came out as "Temp…"). It now sums label, secondary text and aggregate badge, counts the label's2emellipsis floor, and measures the secondary text at the theme's own font size. Applies toautoFitColumn(),autoFitAllColumns()and the resizer double-click. - Fixed (
vanilla-grid):expandAllGroups()/collapseAllGroups()(and the group bar's Expand all / Collapse all) now reach nested groups under a collapsed parent. Before,expandMode: 'collapsed'needed one click per level, and a Collapse all skipped the children of an already-collapsed parent. The result is now the default state for groups that appear later, until the grouping is cleared. Each effective call fires exactly onevn-grid-group-expanded/vn-grid-group-collapsedwithall: true,pathandgroupKeynull. Before, it fired nothing, despite the documentation. - Fixed (
vanilla-grid):setColumns()left the rows in the order the old column definitions produced when a new definition changed how a sorted or grouped column orders (its accessor,type, null placement, temporal parsing, boolean coercion orsortFields). A grouped grid then showed one value as several captions.setColumns()now re-applies the ordering, only when a column the active ordering uses actually orders differently, so a call that changes nothing about ordering stays free. A customsorting.compareValuesreading a display-only property, or anything outside the column, can't be detected; re-sort withsortColumns(). - Maintenance:
people-cities-js(isNumericColumnreadssecondaryLabelonly),.d.tscomments forsecondaryLabelandgetHeaderSecondaryText;build.jsbundles the newaggregate-registry.jsandheader-menu-submenu.feature.js, with the reducers on the fast (hot-path) profile;eslint.config.jslints the benchmark scripts underspecs/;playwright.config.jsgains afirefox-keyboardproject for the caption's Shift+Enter;grid-minimal-jsregisters two custom aggregates (stdev,mode) and uses the new toggle markers. - Fixed (
vanilla-grid): an aggregate change arriving while a group change was still deferred behind the loading skeleton superseded it, building the new levels' projection over the old row order — one caption per contiguous run of a value instead of one per group. The two now coalesce into a single settled pipeline, and neither loses the other's change event. - Fixed (
vanilla-grid): the header menu's aggregate submenu read the configured set as it stood when the menu was built, so a set changed by host code while the menu was open showed a stale check mark and could be overwritten by a later pick. It is now resolved when the flyout opens and again when an item is activated. - Fixed (
vanilla-grid): a group change overtaken by a header sort, asetRows()reload or anappendRows()before it settled was never persisted or announced — a page reload restored the previous grouping, the toolbar missedvn-grid-group-changed, and a'too-many-groups'rejection stayed on screen unreported. It now settles when the overtaking operation finishes: persisted, announced once, and rolled back on too-many-groups. An aggregate change overtaken the same way is likewise persisted and announced once, by whichever operation settles. - Fixed (
vanilla-grid): a secondsetAggregates()while a group change was still deferred no longer loses the group ordering (captions split per contiguous run). - Fixed (
vanilla-grid): a group change rejected for too many groups no longer leaves behind an aggregate set that a joinedsetAggregates()installed. A restored flat grid clears the set; a restored grouping keeps only the entries that still fit it, and announces only a net change. - Fixed (
vanilla-grid): column keys and reducer names such as__proto__,constructorortoStringno longer breakgetGroupAggregates()or pick up inherited footer labels and markers. - Fixed (
vanilla-grid): expanding or collapsing one group no longer costs as much as expanding every group. A caption toggle, orexpandGroup()/collapseGroup()on a visible group, now splices that group's rows in or out of the projection instead of rebuilding it, so its cost follows the group's size, not the dataset's (~0.6 s → a few tens of ms at a million rows). Every group-boundary scan — Collapse all, a group change, sort, filter or search — finds each group's end with a galloping search instead of comparing every row. Toggling a group whose parent is collapsed only records the new state. A customsorting.compareValuesmust be a consistent ordering; grouping relies on it. - Fixed (
vanilla-grid): anexpandGroup(), caption toggle orexpandAllGroups()refused because it would cross the too-many-groups ceiling now leaves the collapse state untouched. Before, the group was recorded as expanded while its caption still showed it collapsed.
Version 1.38.0
- Added (
vanilla-grid): opt-in search-match highlighting. NewhighlightSearchMatchesoption /highlight-search-matchesattribute (off by default) wraps every occurrence of the active search term inside a visible cell's formatted text in<mark class="vn-grid-search-match">. New per-columncolumn.highlightSearchMatches: falseopts a single column out (e.g. anidcolumn where a coincidental numeric substring match would be noise) while the grid-wide flag stays on.- Purely a visual re-match computed at render time from the active search
term and each cell's already-formatted text — decoupled from however the
attached
DataManagerdecided a row matched, so it works identically forStaticDataManager,ODataDataManager, andGraphQLDataManager, including rows arriving via infinite-scroll "load more". A row can therefore match with no cell highlighted (matched a hidden field, a raw value differing from its formatted text, or a server's fuzzy/stemmed ranking) — expected behavior for a visual affordance, not proof of match. - New base CSS rule in
vanilla-grid.csstokenizes six independent visual aspects (--vn-grid-search-match-bg/-color/-weight/-radius/-shadow/-decoration), each with a neutral/off default. Every built-in theme (andTEMPLATE-vn-grid-theme.css) sets its own effect: flat accent-tint fills fordefault/material/fluent, the same plus squared corners forfiori/carbon/carbon-dark, and a shadow-based glow (transparent background, accent text,box-shadow) forglow/glow-dark. grid-minimal-jsandpeople-cities-js(both grids) now ship withhighlight-search-matcheson;grid-minimal-js'sidcolumn demonstrates the per-column opt-out.- See docs/vanilla-grid/02-row-virtualization-and-custom-scroll-implementation.md §1.17 and docs/vanilla-grid/03-data-manager-implementation.md §11.5.
- Purely a visual re-match computed at render time from the active search
term and each cell's already-formatted text — decoupled from however the
attached
Version 1.37.0
Added (
vanilla-grid, events):vn-grid-busy-changed— fired wheneverisBusy()changes value, and only when it actually changes. Detail is{ busy }. Registered asVanillaGridEvents.BUSY_CHANGED, with a matchingonBusyChanged(busy)grid option for callback-style hosts.This is the whole busy lifecycle, which
vn-grid-loading/vn-grid-loadedare not: those bracket a data fetch, while the grid is also busy whenever a large sort, group change or freshly-set dataset reorders behind the shimmer, and while an infinite-scroll page is in flight. Use the new event for anything that tracks whether the grid is working — spinners, busy-gated controls, "please wait" affordances.Fixed (
vanilla-grid, loading lifecycle): the end of a busy window that no fetch closed was previously unobservable.setRows()raises the deferred reorder's shimmer before the element emitsvn-grid-loaded, so a host that greyed itself out on the load pair saw the grid as busy at the exact moment it was told the load had finished — and the shimmer's release fired nothing at all, so it stayed that way. Every load abovesortShimmerThresholdon a sorted or grouped grid was affected, including the initial one.Maintenance (
grid-minimal-js, tests): the sample now persists the whole view rather than half of it. Itspersistenceoverride is gone, so sort state and the filter model join the layout and group domains already stored understorage-mode="local"— the declaredgroup-by/sort-bybecome defaults for a first visit only. The selected theme and row count are the sample's own state and cannot ride on the component's persistence, so a newprefs.jsstores them under agrid-minimal-js:key prefix that both in-toolbar selectors read while building their dropdowns; the theme is restored during parsing, before<vn-grid>upgrades.?rows=still overrides the stored row count, so the Playwright suite can pin a dataset size. Newgrid-minimal-js-persistence.spec.jscovers both stores.
Version 1.36.1
- Fixed (
vanilla-grid, row grouping): grouping a large grid no longer spends the whole reorder looking finished and wrong. The chip appeared at once, and then the grid kept showing the ungrouped rows — grouped column still in place, no captions and no shimmer — until everything changed in one frame about a second later (1279 ms of it at 1,000,000 rows, 97 ms at 300,000). Two independent causes, both now fixed:- The visible column set was re-derived at the very end of the group change, behind the reorder, though it depends on nothing the reorder produces. It is now re-derived up front, in the same frame as the chip, so the grouped column leaves the grid with the chip that grouped it. The tail-end re-derivation is kept, because it is what restores the column when a too-many-groups abort reverts the state.
- A viewport resize during a load re-rendered real rows over the raised
loading skeleton and never put it back. Mounting the group bar resizes the
viewport, so every grouped reorder above
sortShimmerThresholddestroyed its own shimmer ~25 ms in. A resize while loading now re-paints the skeleton at the new size instead, which fixes the same collision for a window resize, a container settling or a theme swap during any load — not only grouping's. The first paint of a group gesture moves from ~5 ms to ~70 ms, because the column set is now re-laid-out before the skeleton goes up.
- Behaviour change (
vanilla-grid,getVisibleColumns()/columns): on a grid abovesortShimmerThreshold, code that reads the column set synchronously aftergroupByColumn()/setGroupState()now sees the post-group column set, where it previously saw the pre-group one untilvn-grid-group-changedfired. No signature changed.
Version 1.36.0
- Fixed (
vanilla-grid,clearPersistedSettings()): resetting the layout no longer freezes the tab. On a 1,000,000-row grid the toolbar's reset command blocked the main thread for about four seconds; it now blocks for about 0.7 s, and 300,000 rows went from 1102 ms to 224 ms. The reset was reordering the same dataset repeatedly and rebuilding the million-entry selection key map twice, with the last and largest reorder running fully synchronously — no shimmer, no sort Worker — while an eligible Worker sort it had already dispatched was thrown away. Four changes, each removing one whole pass:<vn-grid>.clearPersistedSettings()now re-queries the DataManager only when the reset actually changed the query (an active column filter, an active search term, orserver-sort). See the behaviour note below.- When a reload is needed, the in-memory reset no longer reorders ahead of
it: the declarative sort and grouping stay parked and the reload's own
setRows()consumes them, so the dataset is ordered once. setRows()routes its ordering through the shared reorder bracket (runReorderPipeline()) instead of sorting in-thread, so a large reload gets the shimmer and, when the sort is worker-eligible, the off-thread path. BelowsortShimmerThresholdit stays synchronous, and a load with nothing to order skips the bracket entirely, so small grids keep their exact previous timing and synchronous post-conditions. This benefits every large reload — thereloadcommand, a filter change, a search — not only the reset.setColumns()rebuilds the selection key map only when the resolved key column actually moved (or no map exists yet). Re-applying an unchanged declaration spent ~750 ms producing an identical map.
- Fixed (
vanilla-grid, row grouping): the grouping projection was being built twice per grouped reorder — once by the internaldisplayRowswriter and once by the reorder's own completion step. The writer now does the zero-allocation re-alias only, and leaves the build to the reorder. Worth ~600 ms per grouped reorder at a million rows, on every grouped sort and every group change. - Added (
vanilla-grid): adeferToReloadoption onclearPersistedSettings(), mirroring the existing option of the same name onclearSort()— clear the sort and group state and re-park the declarative ones without applying them, because a coordinated reload is about to order every row anyway. Declared invanilla-grid.d.ts. - Behaviour change (
<vn-grid>.clearPersistedSettings()): a reset with no column filter, no search term and noserver-sortno longer issues a DataManager request. Hosts that relied on reset doubling as a "refresh everything" button see one fewer request; thereloadcommand is the correct button for that.vn-grid-persistence-clearedstill fires either way, and the return type is unchanged. Withinfinite-scrollenabled the skipped reload means an already-loaded prefix is kept rather than snapping back to page one — reset is a layout reset, not a data reset. - Behaviour change (
vanilla-grid,setRows()): at or abovesortShimmerThresholdrows with an active sort or grouping,setRows()now returns beforedisplayRowsis ordered and the grid shows its shimmer in the interval. Callers that readdisplayRowssynchronously after a large load must wait forvn-grid-loaded. Unsorted loads and every grid below the threshold are unaffected.
Version 1.35.2
- Fixed (
vanilla-grid, row grouping + viewport sizing): the last row of a grouped grid is reachable again. The group bar's height was missing from_computeAvailableViewportHeight(), so the row snap pinned the viewport to an inline height one bar taller than the space left for it and the container'soverflow: hiddenclipped a strip just under one row tall — the scrollbar reported "fully scrolled" with the final row painted below the visible edge. The bar is now subtracted alongside the header spacer, and it is registered with the grid'sResizeObserverso showing, hiding, or wrapping it to a second line re-snaps the viewport — without moving the scroll position, which the resize path would otherwise snap back to the first row. This also corrects the values that read through_getViewportHeight(): vertical scrollbar metrics, page-up/page-down stepping, and the scroll indicator's visible-row count. No public API change. - Fixed (
vanilla-grid,autoFitColumn()/autoFitAllColumns()): auto-fit no longer measures group caption rows. A caption puts its whole label (Country: Australia — 85 items) into the toggle cell and lets it overflow across the rest of the row by design, so measuring it sized that one column to hold a country name — 210px instead of 90px for anIDcolumn — and made the fitted width depend on which captions happened to be materialized, and therefore on the scroll offset. Caption rows are now skipped, matching the guardmeasureActualRowHeight()already applies in the height dimension. Affects every auto-fit entry point (header menu, resize-handle double-click, the toolbar'sautoFitAllColumnscommand, and the programmatic API). No public API change.
Version 1.35.1
- Fixed (
vanilla-grid, row grouping): freezing columns no longer shifts every group caption to the right. The caption toggle (.vn-grid-group-toggle) is now always placed in the first visible non-internal column; it previously moved to the last frozen column whenever a freeze was configured, so applying a freeze pushed the whole group hierarchy right by the width of the frozen run and unfreezing snapped it back. The toggle stays pinned while scrolling horizontally exactly as before — frozen columns are a contiguous leading run, so the first column is itself frozen whenever any freeze exists. The placement cell now carries avn-grid-group-toggle-cellclass that lifts it above the frozen cells its label overflows across, which would otherwise repaint over the caption and clip it at the freeze boundary. No public API change. - Maintenance (
grid-minimal-js): the sample's in-toolbar theme and row-count selectors now grey out in place for the grid's busy window instead of hiding themselves, so a multi-second load (re-grouping and re-sorting 1,000,000 rows after the toolbar's reset command) no longer empties out the middle of the toolbar row. The busy signal still comes from the toolbar'sstate.busy, and the guard against starting a switch mid-load is unchanged.
Version 1.35.0
- Added (
vanilla-grid, row grouping): client-side, multi-level row grouping. Runs of equal values collapse under a nested, collapsible caption row carrying the value and an exact member count, and it composes with sorting, filtering, selection, freeze and Excel export. Public API:groupByColumn(columnKey, { direction })(establish or replace a single-column grouping),addGroupLevel(columnKey, { direction })(append a level),setGroupState(state)/getGroupState()(read or write the whole ordered chain),clearGrouping(),expandAllGroups()/collapseAllGroups(), and thecanGroupByColumn(columnKey)predicate. The four state-changing calls and the predicate all return aVanillaGridGroupCapability— whether the grouping is (or would be) applied plus a machine-readablereason— so a caller is never left guessing why a request was refused;'too-many-groups'is only ever produced by an operation, never by the predicate. Configure via thegroupingoption group (grouping.columns,grouping.expandMode,grouping.showCount) — with the requested state persisted viapersistence.groupState, on by default — or declaratively with thegroup-byattribute on<vn-grid>(e.g.group-by="country:asc, city:desc"). New events:vn-grid-group-changed,vn-grid-group-expanded,vn-grid-group-collapsed. Grouping is reason-coded and disabled over a partial (infinite-scroll) dataset rather than being silently wrong. - Added (
vanilla-grid, group bar): each applied group level's column leaves the grid — its value lives in the caption — and becomes a chip in a grid-owned group bar above the header, carrying the level's sort direction and an ungroup action, with "Expand all" / "Collapse all" / "Ungroup all" commands at the strip's trailing edge. Chips can be dragged within the bar (or moved withAlt+arrow) to re-nest the levels. Ungrouping restores the column at its original position, width, freeze state and filter.<vn-grid>always supplies the strip; hosts usingVanillaGriddirectly can pass one as thegroupBarelement or let the grid create it. Newmessageskeys cover the bar and the header-menu items ("Group by this column", "Add to grouping", "Remove from grouping", "Ungroup all") including the reason-coded disabled-item titles. - Maintenance (
vanilla-grid,grid-minimal-js, docs, tests): grouping brought supporting work across the grid — the shared reorder pipeline that sorting and grouping now both use, group-aware render projection and virtual pooling, persistence of the requested group state, theming for the caption and bar across every bundled theme, and a?group=noneswitch ingrid-minimal-jsfor specs that need the flat grid. Documented indocs/vanilla-grid/22-grouping-implementation.md.
Version 1.34.0
- Fixed (
vanilla-grid, sorting): the precomputed-key sort path — the Schwartzian O(n) key extraction that keepsDateparsing,String()allocation and locale lookup out of the O(n log n) comparison loop — never ran in a real grid.applySorting()selected it by testing whether the injectedcompareValueswas callable, but the grid resolves that option to the host's function or to its own bound built-in, so it was always callable: every client-side sort took the raw-value pairwise path and paid an extra hop per comparison throughVanillaGrid.prototype.defaultCompareValues. Path selection now useshasCustomCompareValues, the flag derived fromoptions.sorting.compareValuesand already the authority for the same decision in_isWorkerSortEligible(). A host-supplied comparator keeps the pairwise path and its contract (raw cell values plus the real column object) exactly as before. Measured ~5.9x faster on a 300k-row in-thread sort in an--obfuscatebuild. - Fixed (
vanilla-grid, grouping):_scanLevel()'s group-boundary scan called the comparator once per row through the publicgrid.compareValues, which — absent a host override — only forwards to the sorting feature's built-in. It now resolves through_effectiveCompareValues()and calls that built-in directly. Ordering semantics are unchanged (it is the same function); a grouped rebuild of 300k rows measured ~3x faster in an--obfuscatebuild. - Changed (
vanilla-grid, build): the compiled column accessors (column._getValue) and the tri-state boolean coercion moved out ofvanilla-grid.jsinto a new sharedcolumn-access.js(window.VanillaGridColumnAccess). Both run once per row on every render, sort, group-boundary scan and export, andbuild.js'sBUNDLE_FAST_PATHcan only exempt whole files from theheavyobfuscator profile — so a hot helper insidevanilla-grid.jscould not be exempted at all.vanilla-grid.jsbinds both at load time and keeps an inline fallback for partial bundles. No public API change. - Fixed (
vanilla-grid, worker sort): a search, filter, reload or append landing while a deferred sort is in flight can no longer leave the grid showing rows it does not have.VanillaGrid#setRows()/#appendRows()replace or extend the dataset a running reorder was computed against, and on the Worker path that reorder resolves a precomputed array whose elements were read out of the row array captured at dispatch — so its late reply overwrote the correct, freshly-filtereddisplayRowswith rows drawn from an array the grid no longer referenced, breaking the invariant thatdisplayRowsis a permutation ofrows(a search narrowing 200,000 rows to 15,062 could leave 200,000 on screen, 184,938 of them foreign, under a status bar reading "Loaded 15,062/15,062"). Both row-mutation sites already funnel throughbumpDatasetVersion(), which now supersedes any in-flight reorder as well as invalidating the Worker's key cache — so the reload's own synchronous re-sort stands, a chunked extraction still running is abandoned at its next yield, and a future row-mutation site inherits the guard by calling the method it must call anyway. Because a row-data change is not a reorder, the pipeline now also tracks whether it raised the loading state and clears it when superseded this way, so a host callingsetRows()directly with no surrounding load lifecycle cannot strand the shimmer; ownership is what is tested, neverisLoading, so the progressive-chunk load path (loadDataInChunks()) still shimmers across its chunks. No public API, option, attribute, persisted-state or.d.tschange. - Fixed (
vanilla-grid, worker sort): an off-thread sort can no longer order rows by a column definition that has since been replaced. The persistent sort Worker caches each column's precomputed sort keys, and the main thread tracked which columns it held with a boolean residency map keyed bycolumn.keyand invalidated only bybumpDatasetVersion(). BecausesetColumns()recompiles_getValue/_sortGetters/_sortFieldTypeswithout notifying the sorting feature, a grid could sort a large dataset by a column, then receive a different column under the same key — a changedfield/valueGetter/sortFields, or merely a changedtypeorbooleanCoerce— and the next eligible worker sort would send "reuse what you already have" and get back rows in the wrong order. Residency is now decided per column by a signature covering everything that determines the cached bytes: the dataset version, the identity of the compiled accessor, and thetype/booleanCoercethat encode its values — the same design the filter Worker already used (StaticDataManager#_computeColumnSignatures()). Signatures are committed only after the message carrying the values has been posted, which also closes two races by construction: asetColumns()landing mid-extraction can no longer have its stale values re-marked resident by the in-flight pass, and concurrent sorts can no longer claim residency for a column not yet sent (previously asort worker cache missand a silent fall back to the in-thread path). Reuse is unchanged where it was correct — a direction toggle, adding a tiebreaker, or sorting by A, then B, then A still extracts nothing. No public API, option, attribute, persisted-state or.d.tschange. - Changed (
vanilla-grid, worker sort): a sort superseded while its main-thread extraction is still running now abandons the pass instead of finishing it.runReorderPipeline()threads its_sortGenerationtoken into_sortViaWorker()as a liveness predicate, re-checked after every chunk yield; an abandoned pass posts no message, bumps no sequence, and leaves no residency claim. A rapid sequence of header clicks over a dataset aboveSORT_EXTRACTION_CHUNK_SIZE(100,000 rows) no longer runs several full O(n) extraction passes to completion for results that are all discarded but the last. The Worker's own scan of an already-posted message still cannot be un-posted and runs to completion off-main-thread, as before. - Changed (
vanilla-grid, worker sort + filter/search offload): both chunked main-thread extraction passes now yield through one shared primitive, the newmain-thread-yield.js(window.VanillaGridYield). The sort side previously yielded onrequestAnimationFrame, which resolves at a frame boundary and caps a pass at one slice per frame regardless of slice length — roughly a 30% duty cycle. It now uses the same fast-macrotask ladder (scheduler.yield()→MessageChannel→setTimeout) the filter side already used, which keeps the same slice length while raising the duty cycle to ~98%, so a large first-ever sort by a new column spends its wall clock working rather than waiting.StaticDataManagermoved its own copy of the implementation into the shared module;dom-scheduler.jskeepsrequestAnimationFrame, which is correct for paint-aligned work. Internal utility only — nothing added to the public API or the.d.tsfiles.
Version 1.33.3
- Fixed (
vanilla-grid, row-height auto-measurement): body rows no longer come out at a different height on some page loads than others. The grid correctsrowHeightupward by measuring a rendered row, but that measurement could run before the base stylesheet (vanilla-grid.css) had applied — the row then reported the height its unstyledline-height: normalproduces, which is both too tall and dependent on the platform's fonts. Because.vn-grid-body-table tdtakes itsheightfrom--vn-grid-row-heightand a table row can only grow past that minimum, the inflated value pinned the rows to itself, so every later measurement read it back and confirmed it for the rest of the session. The base sheet is requested before the theme sheet but is several times the size of one, so it regularly resolves second — Firefox lost this race often enough to show visibly different row heights across reloads ofgrid-minimal-js(37px vs. up to ~59px on SAP Fiori), while Chromium happened to win it almost every time. The post-load resync now waits for the base stylesheet as well as the theme's, and re-measures from the baseline (the explicitlayout.rowHeight, or the theme's declared--vn-grid-row-height) rather than from the standing correction, so the measurement is idempotent and recovers in both directions. This also closes a smaller mismatch whererowHeightcould sit 1px below the real rendered row (e.g. 36 vs. 37 on Material) and quietly skew the virtual scroller's spacer maths —rowHeightnow always equals the rendered row height. No public API, option, attribute or persisted-state change;measureActualRowHeight()gains an internal{ fromBaseline: true }option used only by the resync path, leaving the render hot path's cost unchanged. Guarded bytests/playwright/theme-load-row-height-race.spec.js, which forces both stylesheet orderings and pins the recovery (verified to fail against the previous implementation).
Version 1.33.2
- Fixed (
vanilla-grid,autoFitColumn/autoFitAllColumns): auto-fit no longer freezes the page on grids with many columns._measureColumnFitWidthmeasured one string at a time — append a hidden probe<span>into the liveth/td, read itsgetBoundingClientRect(), remove it — so every read followed a write and forced a full synchronous re-layout of the table, once per cell. On the 25-columnnorthwind-orders-jssample (51 pool rows) oneautoFitAllColumns()drove ~2000 forced reflows and blocked the main thread for ~1.5s with zero frames painted: the grid appeared hung, then snapped to the fitted widths. Measurement now runs in three phases — append every probe span, read every rect plus the sort-indicatoroffsetWidth, cell widget rects and computed styles in one pass, then detach and compute — collapsing roughly one reflow per cell into one per column. Measured 1514.8ms → 352.9ms (4.3x) on that sample, with all 25 header and body<col>widths byte-identical to the previous implementation. No public API, option, event or persisted-state change:autoFitColumnandautoFitAllColumnskeep their signatures, return values and fitted widths. The internal_measureSpansingleton is replaced by a reused span pool (N probes are now live at once), and the internal_measureTextWidthhelper is removed as unused. Guarded by a newtests/playwright/autofit-performance.spec.js(800ms budget, verified to fail against the previous implementation) and an idempotence test intests/playwright/autofit.spec.jspinning that a probe span never perturbs a neighbouring measurement.
Version 1.33.1
- Maintenance (
people-cities-js,build.js,playwright.config.js,docs/testing-and-coverage.md,tests/): sample-app, build and test tuning only — no component source touched.people-cities-jsnow points at the project's own hosted backend, MINT (Measurement INterface Translator) athttps://people-cities-app.mthome.org, instead ofhttp://localhost:3000:state.js'sBFF_BASE_URLis renamedMINT_BASE_URLand ships the hosted URL as written, so a fresh clone runs the sample with no backend to start locally.build.jsloses theFILE_REPLACEMENTStable and the per-file string substitution it drove inbuildFiles()— the only entry rewrote that localhost URL at build time, which meant the source and the deployed build disagreed about the endpoint. The app's README gains a "The backend: MINT" section describing the BFF's unit-environment translation (X-MU-Environment) and its$metadataunit/decimal declarations. - Maintenance (
tests/,playwright.config.js): de-flaked three specs that were fetching a live third-party service purely to populate a grid.search-term-persistence,filter-panel-localizationandtheme-applies-on-loadasserted on persistence, i18n labels and theming respectively, never on Northwind's data, but Northwind's tail latency under concurrent queries combined with full-suite worker contention could pushsearch-term-persistencepast its 30s row wait — a failure that never reproduced in isolation. They now use a newtests/playwright/_northwind-stub.jsroute handler serving canned rows that honour$top/$skipand thecontains()disjunction the grid emits insearchMode: 'filter'; no assertion was weakened._helpers.js'sBACKEND_URL_PATTERNSwas repointed at the MINT host sobootGrid's backend-blocking route still matches, andINSTRUMENTATION_SENSITIVE_SPECSnow names every spec that lets a remote service respond (addingpeople-cities-js-smoke,wikipedia-pages-js-smoke,late-children-framework-integrationandsample-search-box) rather than only the two third-party ones. Newpeople-cities-js-smoke.spec.jsis the deliberate live-backend exception: an end-to-end check that the sample still works against the real MINT instance.
Version 1.33.0
- Added (
vanilla-grid, temporal columns): new shared temporal parserfeatures/datetime.feature.js, exposed aswindow.VanillaGridDateTimeFeature.date,datetimeandtimecolumns previously had four independentnew Date(value)call sites — cell formatting, the two sorting paths, and the data managers' filter predicates — which could disagree about the same malformed cell. All of them now route through one parser with an explicit{ ok, kind, numeric, raw }envelope that distinguishes null from invalid instead of silently collapsing both. Public API:parseDateValue/parseDateTimeValue/parseTimeValue/parseTemporalValue,compareTemporalValues,formatTemporalValue, and theisDateType/isDateTimeType/isTimeType/isTemporalColumnguards. - Added (
vanilla-grid, column options):sourceFormat('auto'|'epochMs'|'epochSec'|'strict-pattern'),inputPattern,nulls('first'|'last') andtimeZoneonVanillaGridColumnDef.sourceFormat: 'strict-pattern'with aninputPatternsuch as'DD/MM/YYYY'gives locale-ordered feeds a deterministic parse; patterns use fixed-widthYYYY MM DD HH mm ss SSStokens, reject out-of-range values instead of rolling them over (31/02/2026is invalid, never March 3rd), and fall back to'auto'with a console warning when a pattern contains an unrecognized token. - Changed (
vanilla-grid, sorting): temporal ordering is now bucketed — nulls, values, then invalid — honouring the newcolumn.nulls. The default'first'reproduces the previous hardcoded nulls-first ordering exactly, so no existing grid changes order unless it opts in. - Changed (
vanilla-grid, parsing): locale-ambiguous strings such as03/04/2026are now reported asinvalidrather than silently resolved by engine-specific heuristics (which produced March 4th on some inputs andInvalid Dateon others, depending on the day number). ISO 8601 with 1 to 9+ fractional-second digits is accepted and truncated toward zero to milliseconds, so .NETDateTime"O" round-trip timestamps (1960-08-07T14:10:08.2353749Z) parse. Bare finite numbers remain epoch milliseconds. What instant an accepted value resolves to is unchanged. - Fixed (
vanilla-grid,formatValue/_formatByType): the per-columnIntl.DateTimeFormatcache now also invalidates oncolumn.timeZone, andtype: 'time'columns render a formatted clock value instead of falling through to the raw source string. - Changed (
vanilla-grid,StaticDataManagerfilter Worker): temporal filter operands and boxed temporal row values are now resolved to epoch numbers on the main thread (_packCondition, and anumericsidecar from_packColumnValuesChunked) beforepostMessage. The Worker — a separate realm that cannot reach the shared parser — no longer parses date strings at all, so a filter can no longer return different results depending on whether the worker offload engaged. - Maintenance (
people-cities-js,northwind-orders-js,github-repos-js,wikipedia-pages-js,github-repos-react,wikipedia-pages-vue,northwind-orders-angular): the seven hand-rolled detail-panel date formatters now delegate toformatTemporalValue, gaining the per-column Intl cache they lacked.people-cities-js's panel had no temporal branch at all, soBirthDaterendered as the raw wire string; itsHometownobject also rendered as aJSON.stringifydump and now reuses the Cities grid's composite presentation via a sharedformatHometownValue()inmodules/utils.js. - Maintenance (
vanilla-grid, documentation):docs/vanilla-grid/'s 22 documents are nowNN--prefixed in a defined reading order — core engine, data access, sorting/filtering/column types, row interaction, column management, presentation, export, then persistence and performance — so the folder lists in that order.00-index.md's Documents section groups them under those headings, andperformance-analysis.md, previously listed nowhere, is included. Every reference was updated in step (component and sample READMEs, source comments, tests,CLAUDE.md), including four comments that pointed at acache-bust-spec.mdthat has never existed.build.js's generated docs site (dist/docs/index.html) now sorts each group by file name rather than by page title, so it lists the documents in the same order.
Version 1.32.1
- Fixed (
vanilla-grid, column resizing): manually dragging a column that is both sortable and filterable could shrink it past the point where its header still fits — the sort chevron and the filter funnel would overlap and paint on top of each other. The header CSS already reserved space for the funnel (th:has(.vn-grid-filter-icon) .vn-grid-header-labelpadding-right), andautoFitColumnalready measured that reserve correctly, but manual drag-resize used a flat, DOM-independent_getMinColumnWidth()floor of60pxregardless of what the header actually needed. Columns with both affordances now get a96pxfloor instead — derived from the same header chromeautoFitColumnmeasures (padding, ellipsis-floor text box, indicator, funnel reserve) — so manual dragging can no longer push the header into the collision. No public API change;column.minWidth, when explicitly set, still overrides this floor as before. Seedocs/vanilla-grid/11-column-resizing-implementation.md§5.1.
Version 1.32.0
- Added (
vanilla-grid, infinite scroll): a position-independent busy signal for load-more fetches. The grid now toggles avn-grid-loading-moreclass on its container for the whole duration of a page fetch, and paints a smallrole="status"banner ("Loading more rows…") pinned to the bottom edge of the viewport. Previously the only load-more affordance was the skeleton rows at the tail of the data — scroll up mid-fetch and nothing on screen said the grid was still loading, even thoughisBusy()returnedtrue. The banner does not occlude the rows underneath (a load-more append leaves them valid) and needs neither a<vn-grid-toolbar>norlayout.customScrollbarto work. The banner waits out the sameinfiniteScroll.shimmerDelaygrace period as the tail skeleton rows, so a fast backend paints neither and a negativeshimmerDelaystill means "no built-in load-more affordance"; thevn-grid-loading-moreclass is never delayed, so a host indicator can be instant. Newmessages.loadingMorekey (default'Loading more rows…',.d.tsupdated); new styling hooks.vn-grid-loading-more,.vn-grid-load-more-banner,.vn-grid-load-more-banner-shimmer,.vn-grid-load-more-banner-text. - Fixed (
vanilla-grid, infinite scroll): load-more skeleton rows were inserted before the bottom spacer row, i.e. immediately after the virtual pool window rather than after the last row of the dataset. Those coincide only while the user sits at the tail; scrolling up moved the skeleton block into the middle of the data, where_rotatePool()'s owninsertBefore(rowEl, bottomSpacerRow)interleaved recycled data rows through it — the reported "only half the rows shimmer, with real rows below them". The skeletons are now appended past the bottom spacer, which also keeps their extra height out of the per-row offset arithmetic. Rendering at the tail (the only position that triggers a load) is unchanged. No API change. - Fixed (
vanilla-grid, infinite scroll): scrolling up during theshimmerDelaygrace period no longer yanks the viewport back to the bottom when the timer fires. The auto-scroll that reveals the skeleton rows is now gated on the viewport still being near the end of the loaded data; the scrollbar drag-to-bottom hold is unaffected, since a thumb pinned at the end of the track satisfies the same check. - Maintenance (
people-cities-js,northwind-orders-js,github-repos-js,wikipedia-pages-js,wikipedia-pages-vue,anilist-anime-js,github-repos-react,northwind-orders-angular): added thegridLoadingMorestring (en-US / it-IT) to each app's i18n bundle and wired it to the grid'smessages.loadingMore. - Maintenance (
vanilla-grid, all nine sample apps,build.js,playwright.config.js,eslint.config.js,docs/,tests/): every sample app folder renamed from the numberedsample-frontend-Nscheme to a descriptive<domain>-<framework>name (e.g.sample-frontend-2→northwind-orders-js,sample-frontend-9→northwind-orders-angular), so a framework port now sorts next to the plain-JS app it mirrors and adding a tenth sample needs no renumbering. Every path reference, spec filename,<title>, andlocalStoragekey prefix was updated to match; no component API or behaviour changed.
Version 1.31.1
- Maintenance (
vanilla-grid): test and tooling pass across both suites — new Node unit coverage for the interaction feature's scroll arithmetic (wheel normalization, scroll-delta clamping, the momentum guard, near-bottom and horizontal-overflow detection) and forODataDataManager's$filteroperator/literal serialization; new Playwright specs for the custom scrollbars (including the previously untested horizontal scrollbar) and the wheel gesture state machine. No behaviour change from this bullet. Also removed stale working-notes references from the theme stylesheets' comments. - Fixed (
vanilla-grid,hideColumn()/showColumn()/showAllColumns()): changing which columns are visible while the grid was scrolled dumped the user back up the list. Rebuilding the virtual row pool momentarily empties the spacer rows, which collapses the scroll surface and makes the browser clampscrollTop; the code that pre-sized the spacers to prevent that had never run, because it looked the two cells up by class names (vn-top-spacer/vn-bottom-spacer) that no spacer has ever carried — the real cells are held as grid references and their shared class isvn-grid-row-spacer-cell. Measured on a 400-row grid, hiding a column from a scroll offset of 3000 px landed at 1278 px; it now stays at 3000 px. No API change. - Removed (
vanilla-grid, columns feature):getHiddenColumnKeys(). It was a bare alias forgetHiddenColumns()that returned exactly the same array, was never delegated ontoVanillaGridor<vn-grid>, was absent fromvanilla-grid.d.tsand from every README, and was called by nothing but a unit test. Migration: none for hosts — the method was unreachable through any supported surface; usegetHiddenColumns(), which is unchanged.
Version 1.31.0
- Removed (
vanilla-grid,<vn-grid>):getMetadata()and themetadatafield of the lifecycle-event/DataManager context object. Both were dead: the backing_metadatafield was only ever initialised tonulland never assigned anywhere in the codebase, sogetMetadata()always returnednullandcontext.metadatawas permanentlynullfor every host listener. The README describedgetMetadata()as returning "a snapshot of element attributes and context", which it never did. Hosts needing to attach their own data to the context should use thehostsub-bag (context.host.*), which is the supported pass-through and is unchanged. Migration: delete anygetMetadata()call — it can only have been returningnull; readcontext.hostinstead ofcontext.metadata. - Fixed (
vanilla-grid, TypeScript declarations):vanilla-grid.d.tshad drifted from the runtime in three ways, all of which broke TypeScript hosts.VanillaGrid.unfreezeAllColumns()was declared but does not exist — the method isunfreezeAll(), so a typed call compiled and then threw at runtime.VanillaGridElement.setRows()andsetMetadata()were declared but never implemented — the element's method issetData(). And eleven implemented public element methods were missing declarations entirely (hideColumn,showColumn,showAllColumns,getHiddenColumns,canHideColumn,freezeColumn,unfreezeColumn,unfreezeAll,isFrozenColumn,getFrozenColumns,attachGridInstance), so working calls were compile errors. No runtime behaviour changed;docs/was already correct throughout. A newtests/node/dts-conformance.test.jsnow cross-checks every declaration in all three.d.tsfiles against the corresponding prototype, in both directions, so this class of drift cannot recur. - Maintenance (
vanilla-grid,vanilla-grid-toolbar,vanilla-resize-box,tests/,build/tooling): test-infrastructure work with no component behaviour change.npm testnow runs the Node unit suite and the Playwright suite (previously Playwright only, so 666 unit tests were skipped by anyone running the documented command); newtest:unit,test:e2e,test:coverageandtest:coverage:e2escripts; coverage instrumentation for both suites with ratcheting thresholds; a GitHub Actions workflow running lint, unit tests and build. Thirteen files were normalised from CRLF to LF and pinned with a new.gitattributes. Test coverage was raised across the previously thin modules —odata-data-manager(65 → 97% lines),excel-export-delivery(49 → 100%),columns-reorder,columns-resize,interaction-keyboard, the toolbar's command/status/format API and<vn-resize-box>.resize().
Version 1.30.3
- Fixed (
vanilla-grid, header rendering): narrowing a column past a certain point made its header show more characters and drop the ellipsis entirely —Activedegrading toA…and then toActi,Act,Ac, and finally to a blank header. CSS Overflow lets an engine fall back toclipwhen there is no room to paint the ellipsis, and Firefox takes that option once the text box is narrower than one glyph plusU+2026; Chromium keeps painting the ellipsis, so the defect was invisible in a Chromium-only test matrix..vn-grid-header-textnow carries amin-width: 2emfloor that keeps the box above that threshold (font-relative, because the threshold is font-dependent — SAP72renders the ellipsis alone at a full 14px). No public API change. A sorted column whose label is narrower than2emnow renders its sort chevron up to2emfrom the text rather than hard against it; a theme overridingmin-widthon.vn-grid-header-textre-opens the defect. - Maintenance (
grid-minimal-js): demo-app tuning only, no component source touched. Thesalaryandbonuscolumns now passformatOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }so currency renders as whole numbers (display only — sorting, filtering and Excel export still see the full figure); the star-rating column is pinned at 130 px withresizable: falseand exposes its numeric value as a celltitletooltip, which the clipped-star rendering alone can't convey; and the in-toolbar theme and row-count selectors now share one 190 px shell so the two dropdowns line up and the widest row-count label stops being clipped. - Maintenance (
grid-minimal-js,people-cities-js,wikipedia-pages-js,docs/vanilla-grid/15-themes-implementation.md,tests/): sample-app and guard tuning only, no component source touched. grid-minimal-js's frontend-local Apple theme was missing the five required--vn-grid-sort-icon-*tokens and so rendered no sort glyph at all; it now declares them (macOS convention — a thin⌃/⌄chevron on the active sort column only, nothing on unsorted columns at rest or on hover), overrides--vn-grid-filter-icon/--vn-grid-filter-icon-activewith SF Symbols'line.3.horizontal.decreasein place of the base funnel, and stops painting the row-hover tint behind the empty-data message. The static guardtests/node/sort-icon-theme-tokens.test.js— which previously scanned onlysrc/vanilla-grid/themes/, which is why the gap shipped — now also scans hand-authoredsamples/<app>/themes/vn-grid-*.css(generateddist/,vendor/,public/trees excluded), so any theme registered viaVanillaGridElement.registerTheme()from a sample app owes the same token set; the theming doc records that the obligation is on the stylesheet, not on where it lives. people-cities-js's People grid and wikipedia-pages-js's pages grid no longer ship columns frozen by default, and the three Playwright specs that leaned on those defaults (freezing,pool-rotation,reorder-frozen) now establish their own frozen set viafreezeColumn().
Version 1.30.2
- Fixed (
vanilla-grid,autoFitColumn()/autoFitAllColumns()): auto-fitting a filterable column left its header still ellipsized. The header measurement reserved a flat22pxfor the sort indicator against roughly45pxof real chrome — the.vn-grid-header-labelpadding-rightthat holds the filter funnel (th:has(.vn-grid-filter-icon)) plus the indicator and the header-text wrapper's flexgap— so every such column was fitted about23pxtoo narrow and a user who explicitly asked the grid to fit a column gotAct…back. Both reserves are now read from the live DOM, the same way thethpadding already was, so a theme that restyles them (e.g. the Material indicator box) stays measured correctly. No public API change: signatures and options ofautoFitColumn/autoFitAllColumnsare untouched, but auto-fitted filterable columns are now visibly wider. - Fixed (
vanilla-grid, auto-fit header measurement): the fitted width is now rounded up with a 1px cushion. The text measurement is fractional, so a column could settle a fraction of a pixel under what its own label needed and ellipsize anyway — a sub-pixel shortfall that integerscrollWidth/clientWidthcomparisons cannot detect. Residual truncation remains possible where auto-fit does not govern the final width: a column pinned at the dynamicmaxWidthcap, and, under stretch-to-fit, a column the redistribution pass settles below its fit width.
Version 1.30.1
- Fixed (
vanilla-grid,StaticDataManagersearch/filter extraction): the chunked haystack and column-value passes yielded throughrequestAnimationFrame, which capped them at one batch per frame regardless of batch length — a ~5ms batch followed by a ~11ms wait, i.e. a ~30% duty cycle, so most of a large search-index build's wall clock was the main thread sitting idle. They now yield through the fastest available cooperative channel (scheduler.yield(), else aMessageChannelmacrotask, elsesetTimeout). Measured on a 300,000-row, 21-field pack: 2.49s → 1.07s (~2.3x) at a ~98% duty cycle, with the same batch length, so responsiveness is unchanged. A pre-warm also no longer stalls indefinitely in a backgrounded tab (requestAnimationFramestops firing when a tab is hidden). No public API change and no change to search results. - Fixed (
vanilla-grid,StaticDataManagersearch/filter extraction): a slice is now bounded by elapsed time (5ms, probed every 256 rows) as well as by its row count, so very wide rows or an expensivefilterValueGettercan no longer overrun a frame — a row count is only a proxy for cost. Measured on a pathological 4,000-row x 300-field dataset: one 116ms blocking slice before, a 14.1ms worst case after. The row caps remain the upper bound, and search haystack blob boundaries are unchanged (the Worker addresses rows by fixed blob stride, so a budget-triggered yield never closes a blob early). - Fixed (
vanilla-grid,StaticDataManagersurvivor-subset search): a search over a column-filtered subset no longer waits for an in-flight full-dataset search-index pre-warm when packing the subset itself is cheaper. Below one eighth of the dataset it packs immediately instead of inheriting the whole remaining warm — previously a 60,000-row survivor set on a 5,000,000-row dataset could wait tens of seconds for a warm it barely benefited from. Results are identical on either path. - Changed (
vanilla-grid,StaticDataManagersearchFieldsadvisory): the "searching every field of N rows" warning now also fires whenprewarmSearchIndex()starts a real build, not only on the first search. With pre-warming the all-fields extraction happens at load, so a search-only advisory arrived after the cost was already sunk — or never, for hosts whose users don't search. Still one message per dataset version, and still silent whensearchFieldsis configured. - Documented (
vanilla-grid,StaticDataManagerOptions.internStringColumns): the option's precondition is now stated wherever it is described (component README,03-data-manager-implementation.md§5.3, the.d.tsand the constructor JSDoc). Interning only reclaims memory for a column that is low-cardinality and holds a distinct string instance per row — the latter true of rows fromJSON.parse()or per-row computation, false when a field is assigned out of a shared lookup array or a string literal, where every row already references one shared instance. Listing columns that fail either test costs an ingestion pass and reclaims nothing, which the previous "low-cardinality columns" wording did not convey. Adds the measured comparison (500,000 rows: 207.8 MB → 124.8 MB when parsed from a payload, versus no reduction at all for the same rows built in-page) and records why the option lives onStaticDataManagerrather than the server-paged managers. No behavior change. - Maintenance (
grid-minimal-js): the demo now scopes free-text search withsearchFieldscovering 10 of its 21 columns (id,firstName,lastName,email,country,city,department,role,manager,notes) instead of indexing all 21, roughly halving its search-index build and resident haystack memory. All columns remain independently filterable. - Maintenance (
grid-minimal-js): the demo now also setsinternStringColumns: ['manager'], trimming ~16 MB from a 500,000-row run.manageris the only column that qualifies — it is built by concatenation, so every cell is a separate allocation despite ~572 distinct values, whereas the demo's other low-cardinality columns come straight out of literal arrays and are already shared instances. Commented in place so the app is not read as endorsing a column list that would be wrong for server-fed data.
Version 1.30.0
- Added (
vanilla-grid,StaticDataManagerOptions.prewarmSearchIndex): the manager can now schedule its own search-index pre-warm. SetprewarmSearchIndex: trueand it callsprewarmSearchIndex()at idle after every row ingestion (construction withrows, and eachsetRows()), so the one-time haystack extraction lands after first paint instead of on the user's first keystroke — no host wiring. Deferred to idle (a boundedrequestIdleCallbacktimeout,setTimeoutfallback) so the chunked extraction never competes with the initial render; strictly=== true, defaultfalse, and re-warming while the index stays resident is an idempotent no-op. - Added (
vanilla-grid,window.VanillaGridDevMode): opt-in development-only diagnostics. With the global set totrue,StaticDataManageremitsconsole.infomessages on theVanillaGrid:channel when it builds the search index — a start line and an elapsed-time line, each naming the row count, the searched fields and the worker/in-thread path. Emitted only when a build actually begins (never on an already-resident no-op), with a failed build reporting its own outcome. Off by default, so production stays silent. - Removed (
vanilla-grid,VanillaGridElement.prewarmSearchIndex()): the element-level delegate is gone — callgridEl.getDataManager().prewarmSearchIndex()or use the newprewarmSearchIndexoption instead. The index is aStaticDataManagerasset (server-backed managers search remotely and have nothing to warm), so the option and the method now live on the one surface that owns it, and both work for a manager used standalone with no<vn-grid>attached. - Fixed (
vanilla-grid,VanillaGridOptions.dataLoading): the documenteddataLoadingoption threw "unknown option" when passed toinitializeGrid(). The element forwards its whole options bag to theVanillaGridconstructor, which did not listdataLoadingamong its valid keys; it is now accepted (and ignored) there, since the forced-skeleton and Worker-offload mechanisms it drives live at the element level. - Maintenance (
grid-minimal-js): the 500k-row demo now enablesprewarmSearchIndex: trueon itsStaticDataManagerand setswindow.VanillaGridDevMode = true, replacing its hand-rolledvn-grid-loaded+requestIdleCallbackpre-warm listener.
Version 1.29.1
- Fixed (
vanilla-grid,StaticDataManagercolumn filters, Web Worker path): switching an existing filter on a numerically-packed column (number/uid/date/datetime/time) to a type-agnostic unary operator (isEmpty,isNotEmpty,isTrue,isFalse) silently dropped matching rows. The Worker kept the column's numeric packing, in which empty values areNaNand can never satisfy an emptiness test. The same stale-payload reuse also affected a columntypechange over unchanged fields. - Fixed (
vanilla-grid,StaticDataManager.setFilterValueGetters(), Web Worker path): swapping a column's value getter for a different function left the Worker filtering against values extracted by the previous getter, returning stale results. Getter identity is now part of the column's residency signature. - Changed (
vanilla-grid,StaticDataManagerOptions.searchFields): the one-shot "searching every field of a large dataset" advisory now triggers at a fixed 50,000-row threshold instead of trackingworkerThreshold, so it is no longer silenced by disabling the Worker offload (workerThreshold: false) — a host that does so pays the same all-fields cost on the main thread. - Performance (
vanilla-grid,StaticDataManagersearch over filtered data): a survivor-subset search now addresses the Worker's resident full-dataset haystack by row index instead of repacking survivor haystacks on every filter change, and the two are held in independent slots so a filtered search no longer evicts the full-dataset index. AprewarmSearchIndex()investment therefore survives filtered sessions, and clearing filters while a search is active is a cache hit rather than a full re-extraction. Results are unchanged. - Performance (
vanilla-grid,StaticDataManager): when column filters and the search term change together, both are evaluated in a single Worker round trip instead of two sequential ones. - Performance (
vanilla-grid,StaticDataManagercolumn filters): Worker column payloads are tracked per column, so adding or removing one filter column re-extracts only that column instead of re-sending every active column's dataset-sized payload.
Version 1.29.0
- Added (
vanilla-grid,StaticDataManager): scaling work for large in-memory datasets — incremental filter narrowing (re-filtering only re-scans the previous survivor set instead of the full dataset), an indexed survivor search that runs free-text search over the current filtered survivors via the Web Worker path, staged search, and cooperative search cancellation so a superseded search's main-thread result build is skipped instead of racing a newer one to completion. - Added (
vanilla-grid,StaticDataManagerOptions):internStringColumns(top-level string columns deduplicated on ingestion, for memory savings on low-cardinality columns over very large datasets) andcopyOnIntern(write interned values into shallow row copies instead of mutating the caller's rows in place; defaultfalse). - Added (
vanilla-grid,StaticDataManager.prewarmSearchIndex()/VanillaGridElement.prewarmSearchIndex()): opt-in, host-driven search-index pre-warm so the user's first search is a cache hit — typically called onvn-grid-loadedinsiderequestIdleCallback. No-op for data managers without a local index (e.g.ODataDataManager,GraphQLDataManager). - Fixed (
vanilla-grid, header filter panel): changing a filter's operator via pointer (mouse/touch/pen) now moves focus straight into the newly rendered value input, saving a click; operator changes via keyboard are left alone so arrow-key navigation through the<select>isn't hijacked. - Fixed (
vanilla-grid,clearColumnFiltersAndSorting): the coordinated clear-sort step no longer runs its own client-side re-sort/shimmer bracket ahead of the reload, which previously tore down the loading shimmer in ~2 frames and left a blank grid until a slower worker-backed filter/search reload finished landing rows. vanilla-grid,grid-minimal-js,build.js: general tuning and fixes across theStaticDataManagersearch/filter path (search preload, filter behavior) accumulated during development of the above.
Version 1.28.0
- Changed (
vanilla-grid, Material theme): the header filter funnel now uses the Googlefilter_altsilhouette (outline at rest, filled + accent when a filter is active) via the existing--vn-grid-filter-icon/--vn-grid-filter-icon-activetokens, replacing the legacy straight-sided funnel — authored on a taller-than-wide 20×24 viewBox with a Material-only 18×21 glyph-box enlargement (base is 16×16) so it renders larger and taller. - Added (
vanilla-grid, Material theme): SVG "AZ" sort glyphs via the sort-icon theme tokens — a hover-only both-triangle "sortable" affordance on unsorted sortable headers, and single-triangle accent glyphs when sorted (up = asc, down = desc). Firstcontent: url()consumer of the token mechanism (colors baked into the SVGs, since replaced images ignorecurrentColor); all three SVGs share one 16×20 intrinsic size and the Material sort-indicator box is enlarged to match, so state changes cause no layout shift.
Version 1.27.0
- Changed (
vanilla-grid, all themes): sort glyphs are now per-theme tokens with no base default. The base stylesheet no longer hardcodes▲/▼— it only provides the mechanism, reading--vn-grid-sort-icon-asc,--vn-grid-sort-icon-desc,--vn-grid-sort-icon-sortable,--vn-grid-sort-icon-sortable-opacity, and--vn-grid-sort-icon-sortable-hover-opacityon.vn-grid-header-table th. All eight built-in themes declare the full set; the Carbon themes use it for a hover-only⇅sortable affordance with↑/↓direction glyphs. Migration: a custom theme that does not declare the content tokens renders no sort glyphs at all — copy the block fromthemes/TEMPLATE-vn-grid-theme.css(see README "Sort Icons" section and 15-themes-implementation.md §4.5.1). - Changed (
vanilla-grid, Carbon themes): the header filter funnel incarbon/carbon-darknow uses IBM Carbon's ownfiltericon (outline at rest, solid silhouette when a filter is active) via the existing--vn-grid-filter-icon/--vn-grid-filter-icon-activetokens, instead of the shared lucide toolbar funnel.
Version 1.26.0
- Added (
vanilla-grid): automatic loading state on every data-manager load.VanillaGridElement.loadRowsAsync()now brackets every load — the very first one included — withsetLoading(true/false)itself (error path included, generation-guarded so a superseded load can never clear a newer load's state). Hosts no longer wiresetLoadingintoonBeforeFetch/onFetchResponse/onFetchError— those are pure app hooks (status text, raw-Responseinspection, error UI).dataLoading.shimmerThresholdchanged meaning accordingly: it no longer gates whether the loading state is set, only whether the skeleton paint is forced (two-rAF checkpoint) before the fetch begins — below it, a fast same-task load resolves without a skeleton flicker. Public API:loadRowsAsync,setLoading,dataLoading.shimmerThreshold. - Changed (
vanilla-grid,GraphQLDataManager):onFetchResponsenow fires once per user-visible load (fetchRows()), no longer on every infinite-scrollfetchMoreRows()page — matchingODataDataManager's timing.onFetchErrorstill fires for load-more failures. Public API:GraphQLDataManageronFetchResponse/onBeforeFetch/onFetchErroroptions. - Maintenance (
anilist-anime-js): theme-selector polish and AniList-tab tuning (rate-limit status handling, README sync) with matching Playwright coverage; the Year column now renders a-placeholder for titles whoseseasonYearandstartDateare both unknown (the AniList API emits these first under both sort directions — documented in the app README). All sample apps (people-cities-js…-4,-6…-9) dropped their hand-rolledsetLoadingfetch-hook wiring in favor of the automatic loading state above.
Version 1.25.0
- Added (
vanilla-grid): server-side sort now participates in auto-reload. Both built-in server-side data managers —ODataDataManagerandGraphQLDataManager— fire a config-change fromhandleSort()after updating their sort clause, mirroringsetFilter/setSearchTerm. A grid withsetAutoReloadOnConfigChange(true)therefore re-fetches page 0 on sort automatically, so host code no longer needs anonSortChanged: () => reload()callback (that hook is now notification-only). Backward compatible: with auto-reload off (the default),handleSort()'s config-change is a no-op and hosts that reload fromonSortChangedkeep working unchanged. Public API:DataManager.handleSort,onSortChanged,setAutoReloadOnConfigChange.
Version 1.24.3
- Fixed (
vanilla-grid, Carbon & Carbon Dark themes): the active filter-funnel icon was invisible on a column that was both sorted and filtered. These themes fill the sorted header with a solid accent background and flip its text/sort-indicator to an on-accent foreground, but the active funnel — normally the same accent color — was left colliding with that background. It now flips to the on-accent foreground too, matching the sort glyph. Other themes (which use a subtle tint for the sorted background) were unaffected.
Version 1.24.2
- Maintenance (
vanilla-grid,vanilla-grid-toolbar,vanilla-resize-box,people-cities-jsthroughnorthwind-orders-angular,build.js,docs/,tests/): repo-wide folder restructure — the threevanilla-*component folders moved undersrc/, andpeople-cities-jsthroughnorthwind-orders-angularmoved undersamples/. All internal script paths, test references, and documentation links were updated accordingly. No code or public API change.
Version 1.24.1
- Maintenance (
vanilla-grid, docs): corrected the implementation-docs links in this README to point at the repo-level../docs/vanilla-grid/folder (they referenced a staleDocs/path). No code or public API change.
Version 1.24.0
- New
getSearchFields()accessor —DataManager(base, returnsnull),StaticDataManager,ODataDataManager, andVanillaGridElement(gridElement.getSearchFields(), delegating to the attached manager) all gain a read accessor for the fields free-text search is restricted to, mirroring the existinggetSearchTerm()accessor tier.nullmeans unrestricted. Powersvanilla-grid-toolbar's new search-fields tooltip (see that component's changelog). - BREAKING:
StaticDataManager'ssearchableFieldsrenamed tosearchFields— the constructor option andsetSearchableFields()/getSearchableFields()accessors are nowsearchFields/setSearchFields()/getSearchFields(), matchingODataDataManager's existing naming. No shipped sample app used the old name. ODataDataManager.getSearchFields()is now mode-aware: it returnsnull(not the raw configured list) wheneversearchModeisn't'filter'or the list is empty — insearchMode: 'search'the field list was already inert (the server owns$search's scope), but the accessor previously reported it as active regardless..d.ts:VanillaGrid.getHeaderMainText()/.getHeaderSecondaryText()— already existed at runtime (the resolved header-text functions,formatting.getHeaderMainText/getHeaderSecondaryTextor theircolumn.label ?? column.key/column.secondaryLabel ?? column.unitdefaults) but were undeclared; now typed, sincevanilla-grid-toolbar's search-fields tooltip callsgrid.getHeaderMainText(column)to resolve a searched field to its column's header label.- Maintenance:
vanilla-grid(.d.tstouch-ups for the above).
Version 1.23.0
- New
refresh(options?)method onVanillaGridand<vn-grid>— rebinds every visible cell (re-running column formatters /renderCell) against the grid's current in-memory rows, without querying the DataManager. Resets scroll to the top-left by default; pass{ preserveScroll: true }to keep it. This is a new, unrelated method sharing a name with therefresh(options)alias forreload(options)removed as breaking in 1.10.0 — unlike that alias, the new method never re-fetches data, it only re-renders. The internal theme-swap handler (_updateThemeStylesheet()'s theme<link>load callback, which used to call_grid.render()— a method that never existed on the prototype, so the call was silently a no-op) now callsrefresh({ preserveScroll: true })once the new theme stylesheet loads.people-cities-jsthrough-4and-7callrefresh()right aftersetTheme();grid-minimal-jsnow callsrefresh()instead ofreload()after a theme switch, avoiding a full re-fetch of its 500k-row in-memory dataset;wikipedia-pages-vue(Vue) exposes it throughVnGrid/PagesTaband calls it fromonThemeChange. exportToExcel()'sthemeStyleoption now defaults to'auto'(previously unset/unstyled by default) — a bareexportToExcel()call is themed to match the active grid theme automatically; passthemeStyle: falsefor unstyled output.people-cities-jsthrough-4dropped their now-redundant explicitthemeStyle: 'auto'opt-in.- Maintenance:
vanilla-grid(strict-mode /.d.tstouch-ups),people-cities-jsthroughanilist-anime-js,build.js,eslint.config.js— general tuning and fixing.
Version 1.22.0
- Per-column filter operator allow-list — new column-def options
filterOperators(attributefilter-operators="in,equals") anddefaultFilterOperator(attributedefault-filter-operator="in"). The filter panel's operator dropdown becomes type catalog ∩ fan-out-safe set ∩ allow-list (catalog order preserved) and preselectsdefaultFilterOperatorwhen the column has no active filter;normalizeFilterModeldrops conditions using a disallowed operator (the allow-list is carried on the model entry asoperators, likefields), so persisted models and hostsetColumnFilters()calls are enforced too.setColumns()validates both options against the resolved filter type (strict mode throws). Public API:VanillaGridColumnDef.filterOperators/defaultFilterOperator,VanillaGridColumnFilter.operators,operatorsForColumn(type, fieldCount, allowed?). Lets server-side sources whose backend can't honor every operator (e.g. exact-match GraphQL args) offer only mappable operators — adopted byanilist-anime-js.
Version 1.21.0
- New built-in
GraphQLDataManager(window.GraphQLDataManager, auto-loaded and bundled under the heavy obfuscation profile) — a supported DataManager for GraphQL endpoints matchingODataDataManager's generality (server-side sort, structured column filters, free-text search, offset and Relay cursor pagination, total-row-count push, last-wins cancellation). The manager owns everything universal to GraphQL (transport, the{ data, errors }envelope where a GraphQL error is an HTTP 200, cancellation, version-guarded total-count push, config-changed events, response unwrapping via a declared dot-path); hosts supply a query document plus a two-tier config — declarativevariableNamesor imperativebuildVariables/buildSort/buildFilter/buildSearchhooks. A populatederrorsarray throwsGraphQLDataManager.GraphQLError(carrying.graphQLErrors) by default, overridable viaonGraphQLErrors. New public API: theGraphQLDataManagerconstructor and its runtime setters/getters. Demonstrated by the newanilist-anime-js(AniList) app. - Shared filter-model utility gains
sameFilterModel(a, b)— a key-order-independent structural equality check lifted out ofODataDataManager._sameModel, now onwindow.VanillaGridFilterModeland reused by bothODataDataManagerandGraphQLDataManagerfor setter no-op detection (single source of truth). - TypeScript declarations: all three built-in data managers
(
ODataDataManager,GraphQLDataManager,StaticDataManager) are now on the typedwindowsurface with constructor-option interfaces, alongside the baseDataManager.
Version 1.20.0
- New
--vn-grid-filter-actions-justifyCSS custom property controls the layout of the column filter panel's Clear/Apply button row. Defaults toflex-end(both buttons grouped at the trailing edge, matching Carbon's own dialog convention —vn-grid-carbon/vn-grid-carbon-darkdon't override it). Every other built-in theme (vn-grid-default,vn-grid-material,vn-grid-fiori,vn-grid-glow,vn-grid-glow-dark,vn-grid-fluent) now sets it tospace-between, pinning Clear to the leading edge and Apply to the trailing edge instead. Custom themes can opt in the same way; the token is also applied togrid-minimal-js's custom Apple theme (themes/vn-grid-apple.css). Seedocs/vanilla-grid/15-themes-implementation.md§4.6. No breaking change — themes that don't set the token keep today'sflex-endbehavior. - Fixed the filter panel's Apply button losing contrast on hover in every
built-in theme, in some cases (
vn-grid-glow-dark) to a literal background/text color match that made the label fully invisible. The:hoverrule used to reuse--vn-grid-menu-item-hover-bg— a tint sized forcolor: inheritmenu items — against the button's fixed--vn-grid-filter-apply-color. It now derives the hover background from the button's own accent instead (darkened for themes with light Apply text;vn-grid-carbon-dark/vn-grid-glow-darkoverride it to lighten instead, since their accent is lighter than their fixed dark Apply text). - Fixed Carbon/Carbon Dark's row height (
--vn-grid-row-height) — was 40px while the header was 48px; Carbon uses one row-height tier for both header and body rows, so it's now 48px in bothvn-grid-carbon.cssandvn-grid-carbon-dark.css. Also fixed the Carbon toolbar (vn-grid-toolbar-carbon(-dark).css) sitting 6px shorter than the grid header (42px vs. 48px) by adjusting its padding and command-button height. - Fixed
--vn-grid-row-height/--vn-grid-header-heightsometimes never picking up the active theme's value when multiple<vn-grid>instances on a page share the same theme stylesheet<link>: the post-load resync used a singlelink.onloadassignment, so whichever instance called_updateThemeStylesheet()last silently overwrote every earlier instance's callback, and an instance that started observing an already-loaded shared link never got notified at all. Replaced with a per-link waiter list (vanilla-grid-element.js) so every instance gets its own callback, including when the link already finished loading before it attached.
Version 1.19.3
- Fixed the search/filter/sort Web Worker offload blocking the main thread
on very large datasets, and made it dramatically cheaper on repeat calls:
the offload only ever covered the actual match/compare step — extracting
haystack strings and column values (search/filter) or
column._getValueresults (sort) always ran synchronously on the main thread and was re-done from scratch on every call, even a search-term-only keystroke or a sort direction toggle. Measured on a 5,000,000-row dataset, this extraction pass alone took ~31 seconds and itspostMessage()clone cost alone could exhaust memory. Fixed with four changes, applied to bothStaticDataManager's filter/search worker and the sort worker: (1) both Workers are now persistent across calls (search/filter already was; sorting's_cancelPendingWorkerSort()— which killed and recreated the Worker before every sort — is removed) and cache already-extracted data keyed by an extraction signature (search/filter: two INDEPENDENT signatures — dataset version + searchable fields for haystacks, dataset version + active filter columns' field/getter shape for column values) or per sort-column (column.key+ dataset version), so a call that doesn't change the underlying data reuses what's already resident instead of re-extracting and re-cloning it; (2) search haystacks are packed into a handful of moderately-sized "chunk blob" strings (one per 2,000-row batch) + twoUint32Arrayoffset/length arrays instead of one boxed string per row, and number/date filter values as a transferableFloat64Arrayinstead of a boxed array, cutting the one-time clone cost that caused the measured out-of-memory case — deliberately an array of chunk blobs rather than one joined string for the whole dataset, since joining 5,000,000 rows' worth of haystack content into a single string hit V8'sRangeError: Invalid string lengthceiling even after the per-row clone cost was fixed; (3) the one-time (re)extraction pass, when it does need to run, is chunked across animation frames so it never blocks the main thread for its whole duration in one synchronous sweep — haystack building (2,000 rows/batch) and column value extraction (100,000 rows/batch, ~2 orders of magnitude cheaper per row) use different batch sizes so neither starves the other; (4) haystack building/sending is now gated on whether a search term is actually active (StaticDataManager's_workerHaystackSignature, tracked independently of_workerColumnSignatures) — a pure column-filter call with no search term never builds or sends haystacks at all, since apostMessage()clone of that payload is itself an un-chunkable synchronous main-thread stall that would otherwise defeat the point of the offload for a call that will never search them (measured: a 250,000-row, 21-column dataset's haystack clone alone cost ~215ms). A cache-miss safety net (main-thread/Worker bookkeeping desync, e.g. after a Worker error) surfaces as an explicit error and falls back to the in-thread path rather than silently mis-filtering/mis-sorting. No public API or matching/sorting semantics changes — same results, faster and non-blocking. Verified on real datasets up to several million rows: extraction time scales linearly with no memory blowup; remaining slowness at the extreme end traces to the browser tab's own heap ceiling (holding both the raw rows and the derived search index at once), a capacity limit rather than a defect — seedocs/vanilla-grid/03-data-manager-implementation.md§5.2. Affectsvanilla-grid(data-managers/static-data-manager.js,features/sorting-worker.feature.js,features/sorting.feature.js,vanilla-grid.js).
Version 1.19.2
- Fixed selected rows silently vanishing from
getSelectedRows()andexportToExcel({ scope: 'selected' })after a filter/reload excluded them: selection state was split across_selectedKeys(never pruned by a filter) and_keyToRow(rebuilt from scratch on everysetRows(), so a filtered-out row's key silently lost its lookup entry).getSelectedRows()read from_keyToRow, so it quietly returned fewer rows thanselectedCountimplied — in the worst case producing an empty Excel export while the toolbar still read "N selected". Added_selectedRowsCache: Map<string, row>inselection.feature.js, a row snapshot keyed identically to_selectedKeysand populated/cleared at every point a key is added to or removed from the selection;rebuildKeyToRowMap/addRowsToKeyMapadditionally refresh cache entries for rows that come back into view.getSelectedRows()now reads from this cache instead of_keyToRow, so selection survives filter/reload the same way sticky select-all already does, and the existing filtered-out-rows-appended-last reconciliation logic inexcel-export.feature.js'sexportToExcel({ scope: 'selected' })now runs for real instead of having nothing left to reconcile. No public API changes.
Version 1.19.1
- Fixed a rendering glitch on the "no rows" empty state:
showEmpty()'s single centered.vn-grid-empty-messagerow never fills the viewport height, which left two artifacts visible below/on it — a meaningless row-hover tint on the message row itself, and the viewport'srepeating-linear-gradientplaceholder-stripe backstop (normally there to cover transient loading gaps) bleeding through as a series of phantom empty rows. Fixed with two CSS-only rules: every theme's.vn-grid-body-table tbody tr:hovernow excludes:has(.vn-grid-empty-message)(all 8 official themes +TEMPLATE-vn-grid-theme.css), and.vn-grid-virtual-list-viewport:has(.vn-grid-empty-message)drops just the stripedbackground-image, keeping each theme's own flat--vn-grid-placeholder-gaptone. No JS or public API changes.
Version 1.19.0
- Sorting now flips
isLoadinglike filter/search already did: a local sort deferred pastsorting.shimmerThreshold(default5000) — whether running in-thread or offloaded to a Web Worker pastsorting.workerThreshold— now flipsgrid.isLoading(and thereforeisBusy()) true for its duration, exactly mirroring how filter/search/reload already gateisLoadingaroundDataManager.fetchRows()(dataLoading.shimmerThreshold). Previously a large sort leftisLoadinguntouched, so wheel/keyboard scrolling stayed live and anydisabled-when="isLoading"<vn-grid-toolbar-command>stayed enabled during a sort, while an equally large filter/search froze both — the two operations now follow the same rule. Sorts belowshimmerThresholdare unaffected (noisLoadingflip, matching the existing no-shimmer behavior). No new public option was added;sorting.shimmerThresholdis the only gate, same as before.
Version 1.18.0
- Mobile touch interaction hardening (iOS long-press selection/callout,
page bounce/pull-to-refresh, input focus-zoom, tap-highlight/double-tap
zoom):
.vn-grid-table-containersets-webkit-tap-highlight-color: transparentand-webkit-touch-callout: none.- Header-cell selection suppression moved from the fragile
th[style*="cursor: pointer"](sortable columns only) to a blanket.vn-grid-header-table thrule covering every header cell, and gained the-webkit-user-selecttwin for pre-16.4 iOS Safari; header cells also gettouch-action: manipulation(previously onlyth[draggable="true"]) to stop double-tap zoom on the whole header strip. - Body cells (
.vn-grid-body-table td) and the export overlay gained the-webkit-user-selecttwin alongside their existing unprefixed rule. Body cells now readuser-select/-webkit-user-selectfrom a new--vn-grid-cell-user-selecttoken (defaultnone) so a host can opt a specific grid back into selectable cell text (e.g. copyable IDs/emails inrenderCelloutput). - Column-filter panel inputs (
.vn-grid-filter-operator,.vn-grid-filter-value) raise their font-size tomax(current, 16px)under@media (pointer: coarse), avoiding iOS Safari's input-focus page zoom (triggered below 16px, persists after blur). - New "Mobile / touch integration" section in
docs/vanilla-grid/00-index.mddocuments the full contract, including the host-owedoverscroll-behavior-y: noneline needed to stop document-level rubber-band bounce and pull-to-refresh (the component's ownoverscroll-behavior: noneon the viewport only contains gestures that start inside it) — adopted inpeople-cities-jsthroughwikipedia-pages-vue. - No
.d.tschanges — CSS-only.
- Maintenance (
vanilla-grid,vanilla-grid-toolbar,people-cities-jsthroughgrid-minimal-js,build.js): fixed annpm run build:obfuscatebug wherevanilla-grid.bundle.js/vanilla-grid-toolbar.bundle.jsconcatenate many independently-obfuscated feature files — eachjavascript-obfuscatorcall generated its own hexadecimal helper names (string array, decoder, control-flow dispatch) with no awareness of sibling chunks, so two files could end up declaring the same top-level name; the one loaded second silently clobbered the first's string-array state, surfacing as random<name> is undefinedruntime errors in the built bundle. Every chunk in both bundles now gets a uniqueidentifiersPrefix(javascript-obfuscator's documented fix for this exact scenario), verified across repeated fresh builds with no collisions and no runtime errors. Also:people-cities-jsthroughgrid-minimal-jsare no longer obfuscated at all under--obfuscate(only minified, same as a plainnpm run build) — the demo/integration code isn't the IP the build protects;wikipedia-pages-vue(Vite build) was already unaffected.
Version 1.17.0
- Improved scrolling performance and feel, especially on touch/mobile devices:
- Touch-aware synchronous rendering: while a touch gesture or its
momentum tail was recently observed (tracked via passive
touchstart/touchmovelisteners on the viewport),handleScrollskips the rAF deferral for small scroll deltas and renders synchronously, removing a frame of latency from the input mode with the least compositor headroom. - Velocity-skewed buffer window: recent scroll velocity is
EMA-smoothed and used to skew
renderVisibleRows' buffer rows toward the direction of travel (up to a 75/25 split of the fixed-size buffer at high velocity) instead of splitting it symmetrically — same row budget, spent where the compositor is heading. - Coarse-pointer default buffer:
layout.bufferRowsnow defaults to40onmatchMedia('(pointer: coarse)')devices (touchscreens) instead of the desktop default of20, since touch momentum sustains far higher per-frame deltas than wheel input.vanilla-grid.d.tsupdated. - Pool rotation on small scrolls: when a new render window overlaps the
previous one,
_rotatePoolphysically moves only the rows that scrolled out of view to the other end of the pool (insertBefore+ array reorder) instead of repopulating every row — a 5-row scroll now re-renders 5 rows, not the whole pool. - Engine-scoped virtual-DOM height cap:
MAX_VIRTUAL_DOM_HEIGHTis now 16M px on Blink/WebKit (detected via Gecko-exclusive-moz-appearanceCSS support, not UA sniffing) instead of a universal 4M px, keeping more large datasets in low-cost "natural" scroll mode on those engines; Gecko keeps the original 4M px cap where the sub-pixel artifact was measured. Manual real-device verification (iOS Safari, Android Chrome/Edge, a fractionally-scaled Windows touch display) is still outstanding — seedocs/vanilla-grid/02-row-virtualization-and-custom-scroll-implementation.md. - Deferred buffer-row fill in scaled mode: on a scaled-mode render pass, only on-screen rows are populated synchronously; off-screen buffer rows are filled in a following scheduler write, halving the worst-case synchronous cost of the most expensive render path.
- Added a themeable placeholder-stripe background (
--vn-grid-placeholder-gap/--vn-grid-placeholder-stripe) on the viewport and row-spacer cells, so any compositor-exposed gap before the row pool catches up reads as a striped placeholder instead of a flat white/void flash. All built-in themes andTEMPLATE-vn-grid-theme.cssset these two tokens. - New Playwright coverage:
tests/playwright/pool-rotation.spec.js,tests/playwright/scaled-mode-boundary.spec.js,tests/playwright/touch-momentum-sync-render.spec.js.
- Touch-aware synchronous rendering: while a touch gesture or its
momentum tail was recently observed (tracked via passive
grid-minimal-js: theratingandheightcolumnrenderCells now reuse their wrapper elements across virtual-pool re-renders (updating only the parts that depend on the row's value) instead of rebuilding from scratch every render pass, since these renderers sit in the grid's hottest loop.
Version 1.16.0
- Added Sort ascending / Sort descending / Clear sorting items to the
header right-click context menu for sortable columns (omitted when
sorting.enabledisfalseor a column hassortable: false, matching the existing "Filter…" gating). Each item self-disables when it no longer applies (e.g. "Sort ascending" once already ascending); all three act on only the clicked column's place in the sort chain (sortColumn(index, direction, { multi: true })), leaving any other column's multi-sort untouched. Newmessages.sortAscending/messages.sortDescending/messages.clearColumnSortkeys (.d.tsupdated). - Fixed a header-menu theming bug:
--vn-grid-filter-accent/--vn-grid-filter-apply-coloralways resolved to the neutral default, never a theme's override, because the default (declared on the more-specific.vn-grid-header-context-menu.vn-grid-filter-panelselector) outranked a theme's override (on the plain menu class) by CSS specificity regardless of<link>order — the column filter panel's Apply button never actually reflected the active theme's accent color. All--vn-grid-menu-*/--vn-grid-filter-accent/--vn-grid-filter-apply-colortokens are now declared on a single dual selector (.vn-grid-table-container, .vn-grid-header-context-menu) across the base stylesheet, all 8 built-in themes,TEMPLATE-vn-grid-theme.css, and grid-minimal-js'sappletheme —header-menu.feature.jscopies them from the owning grid's container onto the (body-appended) menu/filter panel at open time, keeping the menu in sync with its own grid's active theme. Custom themes must adopt this dual-selector convention going forward (documented inthemes/README.mdand the TEMPLATE); this does not provide true CSS isolation for two<vn-grid>instances running genuinely different themes at the same time — a known, pre-existing, wider gap in container-level theming — seedocs/vanilla-grid/15-themes-implementation.md.
Version 1.15.2
- Fixed
hasMoreRows()transiently returningtruefor a non-infinite-scroll (static/in-memory) grid during the reset→requery window of a filter, sort, or reload._resetInfiniteScrollState()now seeds_hasMoreDatafrom the grid's infinite-scroll mode (!!infiniteScroll) instead of forcing ittrue, sohasMoreRows()is alwaysfalsefor such a grid — matching the load-success invariant. This stops a spurious(scroll to load more)hint from flashing in<vn-grid-toolbar-status>mid-reload..d.tsdoc forhasMoreRows()updated.
Version 1.15.1
- Documentation / inline-comment accuracy pass (comments only — no behavior or
API change): corrected the
DataManagerusage example's option shapes (infiniteScroll: { enabled: true },sorting: { serverSide: true }— the oldserverSort: trueform would have thrown on the constructor's unknown-option guard), and repointed stale pre-implementation spec references (_TODO/FEATURE-COLUMN-FILTERS.md,_TODO/FEATURE-TEMPLATE-COLUMN-SORT-FIELDS.md) to the shippeddocs/vanilla-grid/07-column-filters-implementation.mdanddocs/vanilla-grid/06-sorting-implementation.mdacross the data managers,filter-model.js,vanilla-grid-element.js, andvanilla-grid.js.
Version 1.15.0
- Added persistence of the free-text search term alongside the existing
layout / sort / filter settings. New grouped option
persistence.searchTerm.{enabled, storageKey}— unlike the other domains it defaults tofalse(opt-in), since the search term is transient session state. NewVanillaGrid.persistSearchTermToStorage(term)andgetPendingSearchTerm()(a term loaded from storage before the DataManager is attached is parked and applied once on the first data load, then cleared, mirroring the filter-model restore).clearPersistedSettings()/vn-grid-persistence-clearednow also wipe the persisted search term and clear the DataManager's active term. NewsearchTermfield on theVanillaGridPersistenceOptions.d.ts. .d.ts/ README doc sync: documented thepersistence.sortStateandpersistence.filterModeloption groups (implemented in 1.6.0 but missing fromVanillaGridPersistenceOptionsand the options table) alongside the newpersistence.searchTermgroup.- Fixed:
infiniteScroll.shimmerDelay— a negative value (the documented "never show the load-more skeleton" sentinel) was silently coerced to the500 msdefault by the option parser, so "never show" was unreachable even thoughinteraction.feature.jsgates the skeleton onloadMoreShimmerDelay >= 0. The parser now preserves negative values; only a non-finite value falls back to the default. Corrected the documented default (150→500) in the README and the infinite-scroll doc. Newtests/node/load-more-shimmer-delay.test.jsregression test. - Maintenance (
vanilla-grid): inline-comment / JSDoc accuracy pass acrossvanilla-grid.js,vanilla-grid-element.js, andvanilla-grid.css(corrected_formatByType's handled-types list, a misplaced_autoFitMissingWidthColumnsOnFirstDataJSDoc, and a stale frozen-column freeze-guide CSS comment), and de-duplicated the declarative-column boolean attribute parsing invanilla-grid-element.jsbehind the shared_parseBoolAttr/ new_parseBoolAttrDefaultTruehelpers.
Version 1.14.0
- New
dataLoadingoption group forVanillaGridElement.initializeGrid()—shimmerThreshold(default 5000),useWorker(default true), andworkerThreshold(default 50000). AboveshimmerThreshold,search()/setColumnFilter(s)/clearColumnFilters()/clearColumnFiltersAndSorting()/reload()now show the grid's loading shimmer while the DataManager'sfetchRows()resolves (never on the very first load), gated on a running high-water mark of the largest row count ever loaded rather than the current (possibly already-filtered) count, so the worst case —clearColumnFilters()going from a small filtered set back to the full dataset — is covered.useWorker/workerThresholdare forwarded to the attached DataManager's new optionalsetWorkerOptions()(a no-op for managers that don't implement it). NewVanillaGridDataLoadingOptions.d.tsinterface. StaticDataManager: filter/search evaluation (search term + column filters) can now offload to a Web Worker aboveworkerThresholdrows, keeping the main thread responsive during a large synchronous scan — newuseWorker(default true) /workerThreshold(default 50000;false/ negative /Infinitydisables) constructor options, plus asetWorkerOptions(opts)method (wired automatically frominitializeGrid()'sdataLoadingoption above). Falls back to the existing in-thread path whenWorker/Blobare unavailable, the worker path is disabled, or the dataset is below threshold — filtering results are identical either way. Newdestroy()method to explicitly terminate the worker and revoke its Blob URL (not called automatically by<vn-grid>)..d.tssync: documented the already-existingVanillaGridElement.reload({ preserveScrollBars })method, which had no type declaration.
Version 1.13.0
- Added
infiniteScroll.onLoadMoreSuccess(rows, { loadedRowCount, hasMoreRows })callback + pairedvn-grid-load-more-succeededevent (VanillaGridEvents.LOAD_MORE_SUCCEEDED), dispatched on<vn-grid>every time an infinite-scroll page fetch resolves and its rows are appended (mirrors the existingonLoadMoreError/vn-grid-load-more-failedpair). Fixes a gap whereappendRows()dispatches no event of its own andvn-grid-loadedonly fires for the initial load/reload path — consumers that reflect the running row count (e.g.vanilla-grid-toolbar's<vn-grid-toolbar-status>) had no signal to react to scroll-triggered pagination and appeared stuck at the initial count.
Version 1.12.0
- Breaking: total-row-count state is now push-based, not pull-based.
Replaces the one-shot
onResolveTotalRows/onTotalRowsResolvedresolver (gated by a single grid-owned "attempted once" flag + generation counter) withsetTotalRowCount(count)/getTotalRowCount()grid state, written by whicheverDataManageris attached via a newonTotalRowCountChangedfield_fireTotalRowCountChanged(count)protected helper onDataManager. Fixes a structural bug where two independent reload paths for the same user action (e.g.clearColumnFiltersAndSorting(), which fires both a DataManageronSortChangedreload and its ownreloadDataManager()) could race over the shared one-shot flag, silently discarding the real total. Staleness is now resolved per-manager, per-request via each manager's own monotonic version counter (ODataDataManager._queryVersion) instead of a grid-level generation guard.
- Removed entirely, no shim:
infiniteScroll.onResolveTotalRows,infiniteScroll.onTotalRowsResolved,VanillaGrid.getKnownTotalRows(),VanillaGridElement.loadTotalCount(),DataManager.fetchTotalCount()(all managers, includingODataDataManagerandStaticDataManager),ODataDataManager'sonTotalCountLoadedconstructor option, and theVanillaGridEvents.TOTAL_ROWS_RESOLVED(vn-grid-total-rows-resolved) event — replaced byTOTAL_ROW_COUNT_CHANGED(vn-grid-total-row-count-changed). - Added:
onTotalRowCountChangedgrid constructor option (top-level, not nested underinfiniteScroll— total-row-count applies to any grid a DataManager is attached to),VanillaGrid.setTotalRowCount(count)/getTotalRowCount(). VanillaGridElement.setDataManager()now unconditionally wires the attached manager'sonTotalRowCountChangedtogrid.setTotalRowCount()— passive state sync, independent ofsetAutoReloadOnConfigChange().- Naming cleanup (also breaking):
formatScrollIndicator's argument keys are renamed for a uniform<qualifier>RowCountvocabulary —totalRows→displayedRowCount,loadedRows→loadedRowCount,totalRowsKnown→totalRowCount. The internal_totalRowsLoadedfield is renamed_loadedRowCount(matches the already-correctgetLoadedRowCount(), which is unchanged). - Third-party
DataManagersubclasses that overridefetchTotalCount()stop feeding the grid a total (silent) — migrate to callingthis._fireTotalRowCountChanged(count)fromfetchRows()instead.
Version 1.11.0
- Infinite scroll: failed
onLoadMoreloads now back off exponentially (1s doubling to a 30s cap) instead of retrying on every scroll event. The grid never gives up — a newvn-grid-load-more-failedevent (and aVanillaGrid-levelinfiniteScroll.onLoadMoreErrorcallback) fires on every failure so hosts can surface an error state; a successful load orreloadDataManager()resets the backoff. - Selection events:
selectedKeys/selectedRowson thevn-grid-selection-changeddetail are now lazy compute-once getters — listeners that never read them no longer pay for two full-array allocations on every selection change (e.g. select-all over large infinite-scroll datasets). Event shape and values are unchanged for consumers that do read them. - Sorting: date/datetime/time/string comparators precompute sort keys in
one O(n) pass instead of re-parsing values per comparison (main thread and
worker), cutting large sorts ~3-5x. Custom
compareValueshosts are unaffected._builtinCompareanddefaultCompareValuesare unified into one shared comparator. - Infinite scroll internals:
appendRowsnow adds only the newly appended rows to the row-key map instead of rebuilding it from scratch each page, making large paginated loads roughly linear instead of quadratic. - Startup: the feature-script autoloader now injects all
<script>tags in one pass (parallel download, guaranteed in-order execution) instead of chaining them one at a time, cutting multi-file boot latency dramatically on high-latency connections. Awindow.VanillaGridSequentialLoad = trueescape hatch restores the old chained loader for one release. - Header rendering: per-column event listeners (click/drag/resize/
context-menu) are now delegated once per grid instead of re-created on
every header rebuild, and the per-instance
window.resizelistener leak inrenderHeader()is fixed. - Keyboard navigation: the per-instance document
keydown/keyupand windowresize/blurlisteners are now consolidated into one module-level singleton registry shared across all grid instances (hover-without-focus scrolling behavior is unchanged). Awindow.VanillaGridKeyboardSingleton = falseescape hatch restores the old per-instance listeners. <vn-grid>reparenting: moving a populated grid element to a new DOM parent (drag-and-drop layouts, some framework re-renders) no longer destroys and re-requires re-initialization; teardown is deferred one microtask and skipped if the element is still connected.- Fixed:
setColumns()no longer bakes measured pixel widths back onto the caller'sminWidth/maxWidthcolumn fields for non-resizable columns — the same column array can now be reused across grids/relayouts without carrying over stale constraints, and frozen/immutable column objects no longer throw. - Fixed: the header-checkbox size-sync selector typo (
vg-selection-checkboxvs. the actualvn-grid-selection-checkboxclass) that silently no-op'd header/body checkbox size matching. - Tuning: default
bufferRowshalved (40 → 20), reducing force-render and tooltip work on large datasets with no observed blank-row regression; passlayout.bufferRows: 40to restore the previous pool size. ODatadata manager:fetchTotalCount()now has its own abort controller (previously unwired), socancel()reliably aborts an in-flight count request independently of a concurrent row fetch.- Various lifecycle/CSS/micro-perf hardening:
destroy()now clears the load-more shimmer timer and viewport-adaptation rAF and removes the delegated header listeners;will-change: contentsand redundant GPU layer-promotion hacks removed from viewport/scrollbar CSS; render-loop micro-allocations (checkbox aria-label, per-row selection class checks) moved behind change-gates.
Version 1.10.2
- Maintenance: adopted ESLint (see
eslint.config.js) and cleaned up the findings from its first run — removed a deadthumbHeightcomputation infeatures/interaction.feature.js's_isHoldingScrollbarAtBottom, fixed three regex over-escapes (vanilla-grid.js, rootbuild.js), and normalized pre-existing CRLF/tab and quote-style drift across most component source files (mechanical only — verified withgit diff --ignore-all-spaceand the full Node + Playwright suites, no runtime behavior changed). Also touched:people-cities-jsthroughgrid-minimal-js,build.js.
Version 1.10.1
.d.tssync fix: filled in several public API declarations that had drifted from the runtime implementation —renderCellnow types as(cell: HTMLElement, value: unknown, row: unknown, rowIndex: number) => void(full DOM control over the live cell element, return value ignored) instead of the old value-returning signature;initializeGrid()is now declared to return the constructedVanillaGridinstance;formatScrollIndicatornow takes a singleVanillaGridScrollIndicatorInfoargument instead of three positional numbers; andautoFitColumn,autoFitAllColumns,clearPersistedSettings,setTheme, andwindow.DataManager(the concreteVanillaGridBaseDataManagerbase class for custom data managers) are now declared. No runtime behavior changed — the gaps surfaced while convertingwikipedia-pages-vueto TypeScript.
Version 1.10.0
- Added
<vn-grid>.getColumnFilters(): returns a shallow-copied{ [columnKey]: filter }map of every column with an active filter (an empty object when none, or when the attached DataManager doesn't support filtering). Additive accessor for filter-aware UIs — introduced for<vn-grid-toolbar>'sclearColumnFiltersAndSortinggating, but usable standalone. exportToExcel()'s style precedence changed from all-or-nothing to per-slot:headerCellStyle/dataCellStyle/dataCellStyleAlteach independently use their own explicit value if given, else fall back to the resolvedthemeStylepalette's value for that slot, else stay unstyled.{ headerCellStyle: myStyle, themeStyle: 'auto' }now themes the data rows too, instead of leaving them unstyled. Callers passing explicit styles for all three slots, or with nothemeStyleat all, are unaffected. See 18-excel-export-theming-implementation.md.- Multi-instance theme
<link>support:<vn-grid>'s theme<link>is now keyed by its resolved stylesheet URL (with a refcount), not a single fixed id, so two<vn-grid>instances with differentthemeattributes on the same page no longer fight over one shared link — last writer no longer wins for both. See 15-themes-implementation.md §6.1.2. - Breaking: removed the
refresh(options)alias forreload(options). Callreload()directly.grid-minimal-jsupdated accordingly. - Maintenance:
column-key.js(the shared column-key normalizer) is now wired intobuild.js's bundle parts alongside the multi-file loader, so the bundled artifact and the multi-file build share the same single normalizer instead of the bundle silently falling back to per-feature inline copies;build.jsalso gained support for buildingvanilla-grid-toolbaras a component. General tuning/fixing also touchedpeople-cities-jsthroughwikipedia-pages-vue(mostly Carbon-theme spacing polish and toolbar integration).
Version 1.9.0
- Grid-owned, opt-in themed Excel export.
exportToExcel({ themeStyle: 'auto' })now colours the spreadsheet to match the grid's active theme; the defaultexportToExcel()call stays unstyled (fully back-compatible). The styling logic that hosts previously duplicated now lives in the grid.- New
exportToExceloptions:themeStyle('auto'|VanillaGridExportPalette|false) andheadersOnly(keep the header style, drop data-cell styling). - Each built-in theme stylesheet (and
TEMPLATE-vn-grid-theme.css) declares a--vn-grid-export-*palette on.vn-grid-table-container, read at export time via the newVanillaGrid.prototype._readThemeExportPalette()(mirrors_readThemeRowHeight()). Custom CSS themes get themed export for free. - New static registry on
<vn-grid>:VanillaGridElement.registerThemeExportStyle(name, palette)/getThemeExportStyle(name)— the export-palette analog ofregisterTheme. people-cities-jsdropped its duplicatedgetExcelThemeStyles()palette and opts intothemeStyle: 'auto'.- See Excel Export Theming.
- New
Version 1.8.0
- Added
<vn-grid>.clearColumnFiltersAndSorting(): clears every column filter and all sorting (including their persisted values) with a single coordinated reload, while preserving persisted column layout (widths, order, hidden, frozen). A middle-ground reset betweenclearColumnFilters()(filters only) andclearPersistedSettings()(everything).
Version 1.7.0
- Added additive, backward-compatible API consumed by the new
<vn-grid-toolbar>component (all optional; the grid behaves identically when unused):VanillaGrid.isBusy()and<vn-grid>.isBusy()/<vn-grid>.isLoading— a busy flag spanning the initial/full load and infinite-scroll "load more".<vn-grid>.getSearchTerm()— reflects the attached DataManager's active free-text term (companion to the existingsearch()).- New
vn-grid-total-rows-resolvedevent (inVanillaGridEvents) dispatched when the infinite-scroll total resolves (or is determined unknown);detail.totalRowsisnullwhen unknown. Any hostonTotalRowsResolvedcallback still runs.
Version 1.6.0
- Added persistence of sort state and column filter model alongside the existing layout settings. New grouped options
persistence.sortState.{enabled, storageKey}andpersistence.filterModel.{enabled, storageKey}(both enabled by default) control whether the active sort columns and filters are written to the configured storage provider and restored on the next load. - Added
persistFilterModelToStorage(model)andgetPendingFilterModel()onVanillaGrid. A filter model loaded from storage before the DataManager is attached is parked and applied once on the first data load, then cleared (_pendingFilterModel), avoiding a write-back cycle during restoration. - Extended
clearPersistedSettings()/vn-grid-persistence-clearedto also wipe persisted sort state and filters, clear the DataManager's active column filters, and reload so the grid reflects the cleared state instead of the previously filtered/sorted rows. - Maintenance (generic tuning & fixing):
build.js: fixed the build script — skip copying.d.tsfiles intodist/, and auto-runnpm installinsidewikipedia-pages-vue/whennode_modulesis absent so clean CI/cloud builds no longer fail on the Vite step.- sample apps: tuning of the sample frontends to exercise the new filter/sort persistence.
Version 1.5.0
- Added column filters: a type-aware filtering UI in the header context menu plus a shared filter model (
filter-model.js) used by both the UI and the data managers. Operator catalog covers string (contains,notContains,equals,notEquals,startsWith,endsWith,isEmpty,isNotEmpty,in), number/date (between,notBetween, ordering comparisons) and boolean columns, with per-operator arity driving the input count. - Added filter API on both
<vn-grid>and the data managers:setColumnFilter(),setColumnFilters(),getColumnFilters(),clearColumnFilters(). - Added
vn-grid-column-filter-changedevent ({ columnKey, columnFilter }) dispatched when a filter is applied or cleared from the header-menu UI; the element listens and callssetColumnFilter(). - Added multi-field fan-out filter semantics: negative/exclusion operators join across backing fields with AND, positive operators with OR, keeping fanned-out columns honest. Server-side OData filtering is delegated through the OData data manager.
Version 1.4.3
- Fixed the infinite-scroll "double-load glitch": on server-side grids the total-row count (
onResolveTotalRows, e.g. OData$count) was fetched after the data page rendered, staging a late second round-trip and a visible reload flicker on every filter/sort change. The count is now requested at load start (concurrently with the data page, viasetLoading(true)) — same number of requests, no staggered re-render. The post-render call remains as a fallback for synchronoussetRows()paths and is de-duplicated by the one-time flag. - Added a generation guard (
_totalRowsGeneration, bumped on every_resetInfiniteScrollState()): a slow in-flight count for a superseded predicate (e.g. the previous filter) is discarded instead of overwriting the current total /hasMoreRows/ scroll indicator. No public API change.
Version 1.4.2
- Added
clearPersistedSettings()on bothVanillaGridand<vn-grid>to wipe persisted layout settings (widths, order, hidden, frozen) and restore the declarative defaults in one call. The web-component proxy always returns aPromise<void>and dispatchesvn-grid-persistence-clearedon completion. - Added
clear(keys)to the storage-provider contract. The base class provides a default per-keyremoveItemimplementation;VanillaGridLocalStorageProviderinherits it;VanillaGridNullStorageProvideroverrides it as a no-op;VanillaGridRemoteStorageProvider.clear()is now an explicit interface method that throws unless the subclass overrides it. - Added
stretchToFit(aliaslayout.stretchToFit, attributestretch-to-fit). Default remains the historical "natural width" behaviour (false) but is now documented and gated explicitly. - Fixed a persistence regression in non-stretch mode where the post-
setRowsflex-column pass silently shrank the last resizable column to absorb the vertical-scrollbar gutter, causing the user's last-column resize to be lost on the next reload or layout-mutating action.
Version 1.4.1
- Breaking: Constructor options refactored from flat parameters to grouped configuration objects
formatting:locale,emptyMessage,messages,getHeaderMainText,getHeaderSecondaryText,formatInteger,formatScrollIndicatorlayout:rowHeight,bufferRows,snapViewportToRows,customScrollbar,scrollSpeed,scrollSpeedMultipliersorting:enabled(wassortable),serverSide(wasserverSort),onSort,compareValuescolumns:reorderable(wasreorderableColumns),touchReorder,touchReorderHoldDelay,reorderMarkerDeadZonePxpersistence:columnWidths.{enabled,storageKey},columnOrder.{enabled,storageKey},hiddenColumns.{enabled,storageKey},frozenColumns.{enabled,storageKey}infiniteScroll:enabled(was booleaninfiniteScroll),pageSize,onLoadMore,onResolveTotalRows,onTotalRowsResolved,shimmerDelay(wasloadMoreShimmerDelay)selection:mode(wasselectionMode),rowKeyField,rowKeyGetter,showCheckboxes(wasshowSelectionCheckboxes)
- Added
rowDblClick/vn-grid-row-dblclickevent: fires on row double-click without altering selection state; usespointerdown-based detection for reliable operation with customrenderCellcontent - Added
exportToExcel(options)method on bothVanillaGridand<vn-grid>for exporting grid data to XLSX/CSV/ODS via SheetJS CE - Updated
VanillaGridElement.initializeGrid()to build grouped options - DOM references (
header,body,viewport,headerColGroup,headerSpacer) and top-level callbacks (formatValue,isNumericColumn,decorateCell) remain at top level
Version 1.4.0
- Added row selection:
noselection,single, andmultiplemodes with stable key-based selection, header checkbox, andselectionChangedevent - Added sticky select-all for infinite-scroll mode
- Added
<vn-grid>attributes:selection-mode,row-key-field - Added declarative
<vn-grid-column>children support - Added
setDataManager(),getDataManager()element methods - Added
ODataDataManagerandStaticDataManagerdata layer classes - Added column freeze / unfreeze: context menu,
freezeColumn()/unfreezeColumn()/unfreezeAll()API, persistence, freeze guide line - Added column hide / show: context menu,
hideColumn()/showColumn()/showAllColumns()API, persistence - Added frozen column boundary enforcement in column drag-and-drop
- Added
decorateCellcallback option - Added
getHeaderMainTextandgetHeaderSecondaryTextcallback options - Added
compareValuescustom sort comparator option - Added touch hold-to-reorder (
touchReorder,touchReorderHoldDelay,reorderMarkerDeadZonePx) - Added selection API to web component element:
getSelectedKeys(),getSelectedRows(),setSelectedKeys(),clearSelection() - Extended
messageswithfreezeColumn,unfreezeColumn,unfreezeAllkeys - Added
loadDataProgressively()method for chunked rendering of large payloads - Added keyboard navigation: Arrow, Page, Home, End, Space keys
Version 1.3.0
- Added
vanilla-grid-element.jscustom element support - Added hybrid attribute-driven plus provider-hook integration model
- Added element APIs for
reload(),loadRowsAsync(), andsetDataProvider() - Added reusable endpoint attributes (
data-source,count-source,page-size,infinite-scroll)
Version 1.2.0
- Added optional infinite-scroll total resolver hooks:
onResolveTotalRows(),onTotalRowsResolved(totalRows) - Added resize scroll stabilization for virtualized/infinite grids
Version 1.1.0
- Added column reordering (drag-and-drop), with persistence to
localStorage
Version 1.0.0
- Initial release
- Virtual scrolling support
- Column resizing and sorting
- Multiple themes
- Persistent column widths