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:

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:

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:

attributeChangedCallback(name, oldValue, newValue) behavior:

  1. Returns immediately when value is unchanged.
  2. Special-cases theme: mirrors to dataset.theme.
  3. Emits vn-grid-attribute-changed with { 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:


6. Declarative Columns

_parseDeclarativeColumns() reads all <vn-grid-column> child elements and maps attributes into grid column objects.

Mapping details:

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:

  1. Ensures markup exists (_ensureMarkup()).
  2. Throws if window.VanillaGrid is missing.
  3. Merges provided options into _gridOptions via setGridOptions.
  4. Destroys previous _grid instance if present.
  5. Re-parses declarative columns and stages them (setColumns).
  6. 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:

  1. _gridOptions.sorting.onSort
  2. dataManager.handleSort wrapper
  3. null

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):

7.3 Post-Construction Steps

After new VanillaGrid(mergedOptions):

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:

  1. await this.ready() — wait for persisted settings to be applied
  2. Create context via _createContext(options) (used for lifecycle events)
  3. Emit vn-grid-loading (phase: 'rows')
  4. rows = await dm.fetchRows() — no context parameter; all config is internal to the DataManager
  5. Optional transform: rows = await dm.transformRows(rows) if provided
  6. setData(rows) to grid
  7. Optional post-hook: await dm.onRowsLoaded(rows)
  8. Emit vn-grid-loaded with rows
  9. 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:

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

Fallback behavior when _grid is not initialised:

10.2 Column Visibility API

10.3 Column Freeze API

10.4 Column Auto-Fit API

See Column Resizing Implementation §14 for full details.

10.5 Export API

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

10.7 Grid Attachment / Introspection

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().

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-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):

  1. VanillaGridSelectionFeature.emitSelectionChanged() calls the onSelectionChanged(detail) function it received in its config.
  2. That callback was provided by VanillaGridElement.initializeGrid() and dispatches vn-grid-selection-changed directly on this.

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):

  1. VanillaGrid.handlePointerDown() detects the double-click and calls this._onRowDblClick(detail) if the callback is set.
  2. That callback was provided by VanillaGridElement.initializeGrid() and dispatches vn-grid-row-dblclick directly on this.

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