Row Selection Implementation in Vanilla-Grid
This document explains how row selection is implemented in Vanilla-Grid, including key resolution, mode semantics, sticky select-all behavior, event payloads, and web component forwarding.
1. Feature Scope
Row selection supports:
- Three modes:
noselection,single,multiple - Programmatic selection APIs
- Header checkbox for select-all in multiple mode
- Stable key-based selection across sort/append/reload cycles
- Sticky select-all behavior for infinite scrolling
- Selection-change event dispatch
Relevant options:
selection.mode(defaultnoselection)selection.rowKeyField(defaultnull— no implicit field name assumed)selection.rowKeyGetter(optional function)selection.showCheckboxes(defaulttrue)
See also: Row Key Management for detailed key strategy guidance, compound keys, and OData considerations.
2. Internal State Model
Core selection fields:
_selectionMode_selectedKeys: Set<string>_keyToRow: Map<string, row>_selectedRowsCache: Map<string, row>— row snapshot for every key currently in_selectedKeys, independent of whether that row is in the currently loaded/filtered set (see § 4)_selectAllActive: boolean_headerCheckbox: HTMLInputElement | null_selectionKeyColumnField: string | null
Auxiliary key fallback fields:
_generatedKeySymbolfor generated per-row keys_didWarnFallbackKeysto log fallback warning only once
3. Column and Key Strategy
3.1 Selection column injection
In setColumns(...), if mode is not noselection and selection.showCheckboxes is true, grid injects an internal checkbox column:
- key:
__vgSelection__ - fixed narrow width
- non-sortable / non-resizable
3.2 Key-column detection
_resolveSelectionKeyColumnField(...) scans all columns (_allColumns) for key-column markers — not just the visible subset. This ensures a hidden key column (e.g. a Row ID column the user chose to hide) is still discovered.
Recognised markers on a column definition object:
isKeyColumnkeyColumnis-key-columnkey-column
Any truthy-like value (true, 'true', '1', '') is accepted.
If multiple columns are marked, the first is used and a warning is logged.
3.3 Row key precedence
resolveRowKey(row) resolves a stable key in this order:
rowKeyGetter(row)— highest priority, full control- key-column field from column definitions (resolved via
_selectionKeyColumnField) rowKeyField— explicit field name shortcut- generated fallback UUID-like key (cached on row via
Symbol)
All three named strategies are opt-in only. No implicit field name is assumed — if none of the strategies is configured, the fallback fires and a one-time console warning is emitted.
4. Key Map and Duplicate Handling
_rebuildKeyToRowMap(rows) rebuilds _keyToRow for O(1) row lookup by key. It is used for full data replacements (setRows). Incremental appends (appendRows, infinite scroll) instead call addRowsToKeyMap(newRows), which registers only the new rows — O(page) per append instead of O(total) — while still warning on keys duplicated across pages (it checks the existing map before inserting). hasAnySelection() is a cheap O(1) probe used by the render hot path to skip per-row isRowSelected() key resolution when nothing is selected.
Behavior:
- keeps the last row for duplicate keys
- tracks duplicates and logs warning with counts
_keyToRow is scoped strictly to "currently loaded rows" — every full rebuild clears it, so a row that's been filtered/scrolled out of the loaded set has no entry here. It backs loaded-row lookups elsewhere in the grid, but it is not the source of truth for selected-row data (see _selectedRowsCache below).
Both rebuildKeyToRowMap(rows) and addRowsToKeyMap(newRows) also refresh _selectedRowsCache: for any row whose key is already in _selectedKeys, the cache entry is updated to point at the newly-loaded row object. This keeps a selected-and-then-refiltered-back-in row's cached snapshot current rather than stale.
5. Public Selection API
Methods:
getSelectedKeys()getSelectedRows()setSelectedKeys(keys)clearSelection()isRowSelected(row)
Getters:
selectionState→'none' | 'partial' | 'all'selectAllActive→ sticky select-all flag
getSelectedRows() reads from _selectedRowsCache, not _keyToRow — this is what makes it filter/reload-tolerant: a row selected before a filter is applied stays retrievable even after that filter removes it from the currently-loaded set. Every path that adds a key to _selectedKeys (row click/checkbox toggle, _selectAll/applySelectAllToRows, setSelectedKeys) writes the corresponding row into _selectedRowsCache in the same step; every path that removes a key (manual deselect, clearSelection(), the single-mode "reselect clears" path) deletes the matching cache entry, so the cache never outlives the selection it backs and cannot grow unbounded. Because the cache only ever holds references to already-in-memory row objects (no copies) and is bounded by selection size rather than dataset size, this is the same order of cost as the sticky-select-all bookkeeping described in § 9, not a new one.
5.1 setSelectedKeys(keys)
- requires array input (throws
TypeErrorotherwise) - clears existing selection and
_selectedRowsCache - disables sticky select-all
- inserts normalized string keys, caching the row for any key resolvable via the current
_keyToRow(a key with no currently-known row simply isn't cacheable yet — it becomes cacheable once a matching row is loaded, e.g. viarebuildKeyToRowMap) - emits event + updates header checkbox + rerenders visible rows
5.2 clearSelection()
- disables sticky select-all
- clears key set and
_selectedRowsCacheif needed - emits event when actual selection changed
- updates header checkbox and visible rows
6. Row Interaction Flow
6.1 Row click
handleClick(e) toggles row selection when mode is enabled:
- finds clicked
trin body - ignores direct checkbox clicks (checkbox handler owns those)
- resolves row by
data-vn-grid-row-index - delays
_toggleRowSelection(rowData)by 300 ms so a double-click can cancel it
A row grouping caption row is never selectable and carries no data-vn-grid-row-index at all (omitted entirely, not a sentinel value) — the existing !isNaN(dataIndex) guard in this handler already rejects it with no caption-specific branch, since parseInt(undefined, 10) is NaN. The same omission-based rejection covers the checkbox-change handler (§6.3) and the double-click detector (§6.2).
6.2 Row double-click
handlePointerDown(e) implements a manual double-click detector (two pointerdown events on the same row within 300 ms). The native browser dblclick event is not used because custom renderCell content (e.g. thermometer bars) can be torn down and recreated by the selection re-render of the first click, causing the second click to land on a new DOM node.
When a double-click is detected:
- The pending selection timer from the first click is cancelled — the row's selection state stays unchanged.
e.preventDefault()suppresses text selection.- A
CustomEvent('rowDblClick')is dispatched on the viewport withdetail: { row, rowIndex }.
6.3 Checkbox cell rendering
During renderVisibleRows(...), selection column cells render checkboxes:
- checked state is computed via
isRowSelected(rowData) - checkbox
changehandler calls_toggleRowSelection(rowData)
Selection class vn-grid-row-selected is applied to selected rows.
7. Mode Semantics
_toggleRowSelection(row) behavior:
noselection: no-opsingle:- selecting one key clears all others
- selecting already-selected row clears selection
multiple:- toggles key in set
- manual deselect clears sticky select-all (
_selectAllActive = false)
After mutation: emit event, update header checkbox, rerender visible rows.
8. Header Checkbox and Select-All
In renderHeader(), selection header cell includes a checkbox in multiple mode.
Header checkbox click:
- if current
selectionStateisall→clearSelection() - otherwise →
_selectAll()
_selectAll():
- sets
_selectAllActive = true - adds keys for all currently loaded rows
- emits event, updates checkbox, rerenders
_updateHeaderCheckbox() sets:
- disabled state (no rows or not
multiplemode) - checked/indeterminate based on
selectionState
_syncHeaderCheckboxSizeFromBody() aligns header checkbox dimensions to row checkbox dimensions (queried via input.vn-grid-selection-checkbox). The measured size is cached after the first successful sync: the method is still invoked from every renderVisibleRows() pass but skips the forced layout read until invalidated. Invalidation happens via invalidateHeaderCheckboxSizeSync() on virtual-pool rebuilds (row-height / theme changes funnel through initVirtualPool()) and via setHeaderCheckbox() on header rebuilds.
9. Sticky Select-All with Infinite Scroll
Sticky select-all is integrated at data update points.
In setRows(rows) and appendRows(newRows):
- when
_selectAllActiveand mode ismultiple, incoming rows are auto-selected appendRows(...)emitsselectionChangedafter auto-select updates
This lets "select all loaded" extend naturally to newly loaded pages.
10. Selection Event Contract
_emitSelectionChanged() dispatches CustomEvent('selectionChanged') on viewport with:
modeselectionStateselectAllActiveselectedKeysselectedCountselectedRows
Event bubbles, enabling host-level listeners without direct grid internals access.
Lazy fields: selectedKeys and selectedRows are compute-once-on-first-access getters (defined via Object.defineProperty) — the full arrays are only materialized when a listener actually reads them, avoiding two O(n) allocations per selection change when select-all is active on a large dataset. Consumers reading within the dispatch turn (the toolbar and all sample apps) see identical data to the previous eager fields; a consumer that first reads the arrays after mutating the selection again would see the state at first access.
selectedRows calls getSelectedRows() under the hood, so it inherits the filter/reload tolerance described in § 4/§ 5: selectedCount (from _selectedKeys.size) and selectedRows.length stay consistent with each other across a filter apply, even when the filtered result set excludes some or all of the selected rows.
Selection and Double-Click Interaction
Row selection via single click is intentionally delayed (300 ms timeout). If a second pointerdown arrives on the same row within the delay window, the pending selection toggle is cancelled. This guarantees that double-clicking a row never alters its selection state — only single clicks and checkboxes modify selection.
11. Web Component Forwarding
vanilla-grid-element.js forwards selection APIs:
getSelectedKeys()getSelectedRows()setSelectedKeys(keys)clearSelection()
Element attributes/properties for selection identity:
selection-moderow-key-fieldrowKeyGetterproperty
When grid dispatches selectionChanged, web component re-emits:
vn-grid-selection-changed(bubbles + composed)
When grid dispatches rowDblClick, web component re-emits:
vn-grid-row-dblclick(bubbles + composed),detail: { row, rowIndex }
This keeps selection and row events observable in both imperative and declarative usage styles.
12. Failure Safety and Guardrails
- invalid rows/keys short-circuit safely
- strict type check for
setSelectedKeys - duplicate-key warnings expose unstable identity input
- generated fallback keys keep UI functional when host omits stable keys
Recommended best practice: always provide stable keys (rowKeyGetter, key column, or rowKeyField) to avoid selection drift. See Row Key Management for compound key patterns and best practices.
13. Example Configuration
Using a declarative key column (recommended):
<vn-grid id="myGrid">
<vn-grid-column field="Id" header="Row ID" type="number" is-key-column="true"></vn-grid-column>
<vn-grid-column field="Name" header="Name" type="string"></vn-grid-column>
</vn-grid>
const grid = el.initializeGrid({
selection: {
mode: 'multiple',
showCheckboxes: true
}
});
grid.setSelectedKeys(['1', '42']);
console.log(grid.selectionState); // 'partial' | 'all' | 'none'
console.log(grid.selectAllActive); // sticky select-all flag
Using rowKeyField as an explicit field shortcut:
el.initializeGrid({
selection: {
mode: 'multiple',
rowKeyField: 'rowId'
}
});
Using rowKeyGetter for custom or compound keys:
el.initializeGrid({
selection: {
mode: 'multiple',
rowKeyGetter: (row) => `${row.CompanyId}::${row.OrderId}`
}
});
See Row Key Management for full guidance.