Web Component Implementation (<vn-grid>)
This document describes how the Vanilla-Grid Web Component is implemented in vanilla-grid-element.js, including lifecycle, markup generation, DataManager wiring, API forwarding, and event dispatch.
1. Overview
VanillaGridElement is a light-DOM wrapper around VanillaGrid (no Shadow DOM). It provides:
- Declarative usage via HTML attributes and optional
<vn-grid-column>children - Imperative control via methods (
initializeGrid,setDataManager,reload, etc.) - Direct callback-based routing of grid data events (
vn-grid-selection-changed,vn-grid-row-dblclick) to the host element
Because it is light-DOM, host app CSS and theme files apply directly.
2. Registration and Class Structure
The component class extends HTMLElement and is registered once:
if (!window.customElements.get('vn-grid')) {
window.customElements.define('vn-grid', VanillaGridElement);
}
The constructor initialises internal references:
_grid: activeVanillaGridinstance_dataManager: current data manager instance_gridOptions: merged options used when creating_grid_pendingRows,_pendingColumns: buffers used when data/columns are set before grid init_domRefs: cached DOM references created by_ensureMarkup()_rowKeyGetter: optional custom selection key resolver
The element exposes a get _logger() accessor that returns this._grid.logger when a grid is initialised, or falls back to a module-level _elementDefaultLogger (a thin console.* wrapper) before initializeGrid() has run. All warnings emitted by the element (unknown storage-mode attribute, failed DataManager reloads, etc.) route through this accessor so that options.logger: false or a custom logger passed to initializeGrid() controls element-level output too.
3. Observed Attributes
observedAttributes returns:
['environment', 'infinite-scroll', 'max-rows', 'locale', 'theme', 'theme-css-path', 'selection-mode', 'row-key-field', 'storage-mode', 'stretch-to-fit', 'row-height', 'header-height']
Notable attributes:
row-height— explicit per-grid row height in pixels (positive finite number). When set, it takes precedence over the active theme's--vn-grid-row-heightand is forwarded aslayout.rowHeightto the underlyingVanillaGridatinitializeGrid()time. When absent, the grid adopts the theme's row-height (see Themes Implementation §4.3). The corresponding JS property mirrors the attribute (getterreturnsnullwhen missing/invalid,setterwrites a normalized positive number).header-height— explicit per-grid column-header height in pixels (positive finite number). Same resolution semantics asrow-height: when set it overrides the active theme's--vn-grid-header-heightand is forwarded aslayout.headerHeightto the underlyingVanillaGridatinitializeGrid()time. When absent, the grid adopts the theme's header height (see Themes Implementation §4.4). The JSheaderHeightproperty mirrors the attribute (getterreturnsnullwhen missing/invalid,setterwrites a normalized positive number).
attributeChangedCallback(name, oldValue, newValue) behavior:
- Returns immediately when value is unchanged.
- Special-cases
theme: mirrors todataset.theme. - Emits
vn-grid-attribute-changedwith{ name, oldValue, newValue }.
This callback does not rebuild the grid by itself; it emits state changes and leaves reloading decisions to host code.
4. Lifecycle
4.1 connectedCallback()
Calls _ensureMarkup() to guarantee required table/viewport nodes exist.
4.2 disconnectedCallback()
Teardown is deferred by one microtask and skipped when the element is
already re-connected: reparenting — appendChild into a new
container, drag-and-drop layouts, some framework re-renders — fires
disconnect + connect synchronously, and destroying the grid there would
silently kill a populated grid with no signal to the host. Moving a
<vn-grid> in the DOM therefore keeps it alive; no re-initialization is
needed.
On a genuine removal (still disconnected when the microtask runs):
_grid.destroy() is called to clean up event listeners/timers; then _grid
is nulled and the theme stylesheet link is released. The body is idempotent,
so rapid repeated disconnects are harmless.
<vn-grid-toolbar>'s disconnectedCallback applies the same
microtask-deferred pattern so its grid listeners survive a reparent (its
handler references are stable, making the reconnect re-attach a no-op).
5. Internal Markup Generation
_ensureMarkup() is idempotent (no-op if _domRefs already exists).
If a root node ([data-vn-grid-root]) does not exist, it builds this structure:
<div class="vn-table-container" data-vn-grid-root>
<div class="vn-group-bar" data-vn-grid-ref="group-bar" hidden></div>
<div class="vn-header-spacer">
<table class="vn-header-table" data-vn-grid-ref="header-table">
<colgroup data-vn-grid-ref="header-colgroup"></colgroup>
<thead data-vn-grid-ref="header"></thead>
</table>
</div>
<div class="vn-virtual-list-viewport" data-vn-grid-ref="viewport">
<table class="vn-body-table">
<colgroup></colgroup>
<tbody class="vn-virtual-tbody" data-vn-grid-ref="body">…</tbody>
</table>
</div>
</div>
If <vn-grid-column> children are present, the generated container is appended as a sibling so declarative column nodes remain in place.
The group-bar strip is created unconditionally, exactly like the header spacer and viewport — it is the mount point for the row-grouping group bar. Row grouping removes an applied group level's column from the grid, and the bar's chip is the only interactive way to bring it back once that column's header menu leaves with it, so this element must never be host-supplied markup that can go missing. It stays empty and hidden (occupying no space) until a group state exists. See Row Grouping Implementation § 18.
After creation, _domRefs caches:
root,headerSpacer,headerColGroup,header,viewport,body,groupBar
6. Declarative Columns
_parseDeclarativeColumns() reads all <vn-grid-column> child elements and maps attributes into grid column objects.
Mapping details:
- Copies all element attributes into a plain object
fieldis mapped tokeywhenkeyis missingheaderdefaults to element text contenttypedefaults to'string'- Enforces
key,header,typefallbacks - Sets
field = keyfor compatibility - Sets
label = header || label || keyso VanillaGrid header rendering works
Supported <vn-grid-column> attributes
| HTML attribute | Column property | Type | Default | Description |
|---|---|---|---|---|
field |
key / field |
string | '' |
Data property name |
header |
header / label |
string | field name | Column header text |
type |
type |
string | 'string' |
Data type (string, number, date, datetime, time, checkbox, uuid) |
width |
width |
number | — | Initial width in pixels |
min-width |
minWidth |
number | — | Minimum width in pixels (default floor: 60; explicit values below 60 are honoured) |
max-width |
maxWidth |
number | — | Maximum width in pixels |
is-key-column |
isKeyColumn |
boolean | false |
Marks column as the row identity key |
hidden |
hidden |
boolean | false |
Column starts hidden; user can show via context menu, persistence overrides |
sortable |
sortable |
boolean | true |
Whether the column can be sorted |
resizable |
resizable |
boolean | true |
Whether the column can be resized by dragging |
frozen |
frozen |
boolean | false |
Column starts frozen; user can unfreeze via context menu, persistence overrides |
sort-fields |
sortFields |
string | — | Comma-separated dot-notation field paths used for sorting instead of field/key. Enables template columns (whose renderCell assembles a composite display) to sort by underlying data properties — e.g. "Employee.FirstName,Employee.LastName". Multiple fields are compared in priority order (tiebreaker chain). See Sorting §7.4. |
sort-field-types |
sortFieldTypes |
string | — | Comma-separated type hints parallel to sort-fields entries (e.g. "string,string"). Accepts the same values as column.type. Defaults to 'string' for any omitted entry. Only meaningful when sort-fields is also set. |
Numeric attributes (width, min-width, max-width) are parsed as positive finite numbers; invalid values are ignored. Boolean attributes use standard HTML truthy conventions: bare attribute, "true", or "1" are truthy; "false" or "0" are falsy. For sortable and resizable, the default is true — only explicit "false" disables the feature.
The parsed result is applied at initializeGrid() time.
7. Grid Initialization Pipeline
initializeGrid(options) does the following in order:
- Ensures markup exists (
_ensureMarkup()). - Throws if
window.VanillaGridis missing. - Merges provided options into
_gridOptionsviasetGridOptions. - Destroys previous
_gridinstance if present. - Re-parses declarative columns and stages them (
setColumns). - Builds merged options for
new VanillaGrid(...).
7.1 Options Merging Rules
Merged options include DOM refs; selection, sorting, and infinite-scroll settings are grouped into sub-objects:
{
..._gridOptions,
header, body, headerColGroup, viewport, headerSpacer, groupBar,
sorting: { ..._gridOptions.sorting, onSort },
infiniteScroll: { ..._gridOptions.infiniteScroll },
onTotalRowCountChanged: resolvedOnTotalRowCountChanged,
selection: {
mode: _gridOptions.selection?.mode || this.selectionMode,
rowKeyField: _gridOptions.selection?.rowKeyField || this.rowKeyField,
rowKeyGetter: _gridOptions.selection?.rowKeyGetter || this.rowKeyGetter
}
}
onSort resolution priority:
_gridOptions.sorting.onSortdataManager.handleSortwrappernull
onTotalRowCountChanged is a top-level grid constructor option (not
nested under infiniteScroll) — total-row-count state applies to any grid a
DataManager is attached to, not just infinite-scroll ones. The element wraps
_gridOptions.onTotalRowCountChanged (if any) so every change also dispatches
the public vn-grid-total-row-count-changed CustomEvent. There is no
default/fallback resolver — the grid never pulls for the total; see §8.1.
7.2 DataManager Auto-Wiring
When a DataManager exists, capabilities are detected via duck-type checks (only sophisticated managers like ODataDataManager have these methods):
- If
infiniteScroll.enabledistrue,onLoadMoreis not explicitly provided, anddm.fetchMoreRowsis a function:onLoadMoreis auto-wired todm.fetchMoreRows(skip, pageSize)— no context parameter. - If
pageSizewas not explicitly provided in_gridOptions.infiniteScrollanddm.getPageSizeis a function:pageSizeis set fromdm.getPageSize().
7.3 Post-Construction Steps
After new VanillaGrid(mergedOptions):
- Applies pending columns/rows (
_pendingColumns,_pendingRows) if any. - Returns the created
VanillaGridinstance.
No viewport event listeners are added by the element itself. Selection and row double-click events are routed directly via the callbacks described in §7.1 and §12 below.
8. Data Loading and DataManager Integration
8.1 setDataManager(dataManager)
Stores an object reference (or clears with null). Returns void.
Also unconditionally wires dataManager.onTotalRowCountChanged = (count) => this._grid.setTotalRowCount(count) (detached from the previous manager on
swap, mirroring the existing onConfigChanged detach-on-swap logic) — this is
passive state sync, not a reload trigger, so it is not gated behind
setAutoReloadOnConfigChange(). Any manager that calls
this._fireTotalRowCountChanged(count) therefore automatically updates the
grid's total, which in turn dispatches vn-grid-total-row-count-changed (see
§7.1).
8.2 loadRowsAsync(options = {})
Requires a DataManager; otherwise throws:
throw new Error('No DataManager set. Call setDataManager() before loadRowsAsync().');
Awaits grid.ready() before fetching, ensuring persisted column settings are applied first (important for storageMode: 'remote').
Execution flow:
await this.ready()— wait for persisted settings to be applied- Create context via
_createContext(options)(used for lifecycle events) - Emit
vn-grid-loading(phase: 'rows') rows = await dm.fetchRows()— no context parameter; all config is internal to the DataManager- Optional transform:
rows = await dm.transformRows(rows)if provided setData(rows)to grid- Optional post-hook:
await dm.onRowsLoaded(rows) - Emit
vn-grid-loadedwith rows - On error: emit
vn-grid-error, then rethrow
vn-grid-loaded does not mean the grid is idle. Step 6 clears the loading flag and then
hands the rows to setData(), and on a grid at or above sortShimmerThreshold with a sort or
an applied grouping to produce, that call raises the shimmer again and defers the reorder — so
by step 8 the grid is busy once more, for a window that ends with no lifecycle event of its
own. Anything tracking "is the grid working" must listen to vn-grid-busy-changed (§ 12.1)
rather than bracketing vn-grid-loading / vn-grid-loaded, which bracket the fetch. A
toolbar that greyed itself out on the pair stayed greyed out indefinitely after every large
load.
8.3 reload()
reload() delegates to loadRowsAsync() (rows only).
8.4 refresh(options?)
Rebinds every visible cell against the grid's current in-memory rows — re-running column formatters / renderCell — without calling the DataManager. Delegates to VanillaGrid.refresh(), a no-op when _grid isn't initialized. Resets scroll position to the top-left by default; pass { preserveScroll: true } to keep it. Useful after a live theme swap or any other cosmetic change where cell output may depend on external state but the underlying rows haven't changed. _updateThemeStylesheet()'s theme-link-load handler already calls refresh({ preserveScroll: true }) once a runtime theme swap's stylesheet finishes loading, to rebind cells against the new theme without disturbing scroll position; a host-triggered refresh() (no options) after setTheme() is what resets scroll to the top for a deliberate theme switch.
9. Context Object Contract
_createContext(overrides = {}) returns a merged context object containing:
- Grid-owned fields:
element,grid,dataManager,locale,infiniteScroll,theme host: a sub-bag for host pass-through metadata that the grid does not interpret:host.environment— value of theenvironmentattribute (host application concern)host.maxRows— value of themax-rowsattribute (host page-size concern)
- Deprecated top-level aliases (kept for backwards compatibility, will be removed in a future release):
environment(mirrorshost.environment)maxRows(mirrorshost.maxRows)
- Any caller overrides
The infinite-scroll attribute is parsed via the same tolerant parser used for declarative columns: '', 'true', '1', or the literal boolean true all map to true.
This context is passed to DataManager methods and lifecycle events. Hosts that need to add their own metadata can populate additional keys under host via overrides.
10. API Forwarding Layer
The element exposes a thin forwarding API over _grid.
10.1 Selection API
getSelectedKeys()getSelectedRows()setSelectedKeys(keys)clearSelection()isRowSelected(row)— returnstrueif the row is currently selected;falsewhen the grid is not yet initialized
Fallback behavior when _grid is not initialised:
get...methods return empty arraysisRowSelected()returnsfalse- mutating methods no-op
10.2 Column Visibility API
hideColumn(key)showColumn(key)showAllColumns()getHiddenColumns()canHideColumn(key)
10.3 Column Freeze API
freezeColumn(key)unfreezeColumn(key)unfreezeAll()isFrozenColumn(key)getFrozenColumns()
10.4 Column Auto-Fit API
autoFitColumn(columnKey, options?)— sizes a single column to fit its content (header + visible body cells). Skipped ifcolumn.resizable === false. Options:persist(boolean, defaulttrue),mode('missingOnly'|'overwriteAll'),minWidth,maxWidth. Returnstrueif the column was resized.autoFitAllColumns(options?)— iterates left-to-right through all columns and auto-fits each visible, resizable column. Skips: the selection column, hidden columns, columns withresizable === false. Persists in a single batch at the end. Accepts the same options asautoFitColumn. Returnstrueif at least one column was resized.
See Column Resizing Implementation §14 for full details.
10.5 Export API
exportToExcel(options?)— exports grid data to an Excel (.xlsx) file and returns a Promise, rejected with a codedVanillaGridExportError(EXPORT_FAILED,EXPORT_TOO_LARGE,EXPORT_CANCELLED,EXPORT_IN_PROGRESS). Throws if the grid is not initialized.
Key options: fileName, sheetName, scope ('all' | 'selected'), includeHeaders, columns (array of keys), formatCell callback, useGridColumnWidths (default true — mirrors grid pixel widths in Excel), useColumnTypeFormatting (default true — applies Excel numFmt and right-alignment based on col.type). See Export to Excel for the full list, progress and Cancel.
Column header text is always resolved as col.label || col.header || col.key. If the column has secondary header text (col.secondaryLabel), it is automatically appended: "Height [m]". Internal columns (__vgSelection__, __rowNumber__) are always excluded. Cell values are read via column._getValue(row) — the precompiled per-column accessor installed by setColumns() — so dotted column keys (address.city) and host-supplied column.valueGetter(row) are honored uniformly across rendering, sorting, and export.
10.6 Persistence Reset API
clearPersistedSettings()— wipes every grid-owned settings key from the active storage provider, resets the in-memory width/order/hidden/frozen caches, and re-applies the declarative columns so the grid returns to its initial layout. Always returnsPromise<void>(regardless of whether the underlying provider is sync or async). On completion the element dispatchesvn-grid-persistence-cleared; on failure it dispatchesvn-grid-errorwithphase: 'persistence-clear'. See Local Storage Settings Implementation §6.5 for the full contract.
10.7 Grid Attachment / Introspection
attachGridInstance(gridInstance)getGridElements()setTheme(theme)
setTheme(theme) normalizes input and updates/removes the theme attribute.
11. Pending Data/Columns Buffering
setData(rows) and setColumns(columns) are safe to call before initializeGrid().
- If
_gridis ready, they call_grid.setRows(...)/_grid.setColumns(...)immediately. - If not, inputs are stored in
_pendingRows/_pendingColumns. - During
initializeGrid(), pending values are applied once and then cleared.
This lets host code stage data/columns without coordinating initialisation order.
12. Event Model
All app-facing events fire on the <vn-grid> host element with
bubbles: true, composed: true. This is the single well-known target that
hosts listen on — no matter which internal node originally triggered the
action. (Internal camelCase events such as selectionChanged and
rowDblClick may still be dispatched on the viewport for backward
compatibility, but are not part of the public API.)
12.1 Component Lifecycle Events
Emitted by _emitLifecycleEvent(name, detail) with bubbles: true, composed: true:
vn-grid-loadingvn-grid-loadedvn-grid-errorvn-grid-attribute-changedvn-grid-persistence-cleared— emitted afterclearPersistedSettings()finishes successfully
vn-grid-busy-changed is dispatched the same way but is not a fetch event, so it is listed
apart: it fires whenever isBusy() changes value, and only when it actually changes. Its detail
is { busy }.
The busy window is wider than the fetch window. The grid is also busy while a large sort, group
change or freshly-set dataset reorders behind the shimmer, and while an infinite-scroll page is
in flight — windows that open and close without a fetch, and that used to close in silence. The
routing is the callback pattern § 12.2 describes for selection: VanillaGrid#_notifyBusyChanged()
compares isBusy() against the last value it announced and calls the injected onBusyChanged,
which VanillaGridElement re-dispatches. Both writers of the two flags isBusy() reads —
setLoading() and _setLoadingMore() — go through it, so a nested window (a load-more raised
while a full load already holds the flag) stays one window rather than announcing two.
Use it for anything that tracks whether the grid is working: spinners, busy-gated controls,
"please wait" affordances. <vn-grid-toolbar> recomputes its own state on it, which is what
keeps state.busy from latching (see
vanilla-grid-toolbar/02-implementation.md
§ "State assembly").
12.2 Selection Event
vn-grid-selection-changed is dispatched on <vn-grid> with bubbles: true, composed: true.
Routing (callback-based, no intermediate listener):
VanillaGridSelectionFeature.emitSelectionChanged()calls theonSelectionChanged(detail)function it received in its config.- That callback was provided by
VanillaGridElement.initializeGrid()and dispatchesvn-grid-selection-changeddirectly onthis.
Detail shape:
{ mode, selectionState, selectAllActive, selectedKeys, selectedCount, selectedRows }
12.3 Row Double-Click Event
vn-grid-row-dblclick is dispatched on <vn-grid> with bubbles: true, composed: true.
Routing (callback-based, no intermediate listener):
VanillaGrid.handlePointerDown()detects the double-click and callsthis._onRowDblClick(detail)if the callback is set.- That callback was provided by
VanillaGridElement.initializeGrid()and dispatchesvn-grid-row-dblclickdirectly onthis.
Detail shape: { row, rowIndex }
12.4 Load-More Failure Event
vn-grid-load-more-failed is dispatched on <vn-grid> with bubbles: true, composed: true.
Routing (callback-based, mirrors 12.2/12.3): initializeGrid() wraps the
host-supplied infiniteScroll.onLoadMoreError (if any) with a dispatcher, so
the event always fires and the host callback still runs. The grid keeps
retrying with an exponential backoff (1 s → 30 s cap) and never flips
hasMoreRows() off on failures — see
docs/vanilla-grid/05-infinite-scroll-implementation.md §5.4.
Detail shape: { error, failureCount, retryDelayMs }
12.5 Load-More Success Event
vn-grid-load-more-succeeded is dispatched on <vn-grid> with bubbles: true, composed: true, every time an infinite-scroll page fetch resolves and its rows are appended (even when the page is empty). appendRows() itself dispatches no event, and LOADED only covers the initial load / reload path, so this is the only signal for incremental, scroll-triggered pagination — consumers that need to reflect the running row count (e.g. <vn-grid-toolbar-status>) must listen here in addition to LOADED.
Routing (callback-based, mirrors 12.4): initializeGrid() wraps the
host-supplied infiniteScroll.onLoadMoreSuccess (if any) with a dispatcher, so
the event always fires and the host callback still runs.
Detail shape: { rows, loadedRowCount, hasMoreRows }
Double-click detection uses pointerdown timing (not native dblclick) so
it works reliably even when renderCell replaces cell DOM between clicks.
13. Practical Usage Pattern
const el = document.querySelector('vn-grid');
el.setDataManager(new ODataDataManager({
baseUrl: '/api/items',
countUrl: '/api/items/$count',
pageSize: 500
}));
// Key column is declared via is-key-column="true" on a <vn-grid-column> in the HTML.
// rowKeyField is only needed as an explicit override when no declarative key column exists.
el.initializeGrid({
infiniteScroll: { enabled: true },
selection: { mode: 'multiple' }
});
await el.reload();
el.addEventListener('vn-grid-selection-changed', (e) => {
console.log('Selected keys:', e.detail.selectedKeys);
});
14. Design Notes and Constraints
- No Shadow DOM by design: easier styling and theme integration with existing app CSS.
- Wrapper role only: rendering/virtualization/sorting logic remains in
VanillaGrid; the element composes and forwards. - DataManager-first loading model:
loadRowsAsync()requires a DataManager, ensuring a consistent async contract. - Idempotent markup creation:
_ensureMarkup()can be called repeatedly without duplicating DOM. - Safe pre-init API calls: pending buffers reduce ordering hazards in host code.