Skip to content

Custom Plugins

Learn how to extend @toolbox-web/grid with your own plugins. Plugins can add new features, modify data, inject styles, and respond to user interactions.

All plugins extend BaseGridPlugin and implement lifecycle hooks:

flowchart TB
    A["attach()<br/><i>Called when plugin is added to grid</i>"]
    B["processColumns()<br/><i>Transform column definitions</i>"]
    C["processRows()<br/><i>Transform row data</i>"]

    subgraph render["For each visible row"]
        direction TB
        D["afterCellRender()<br/><i>Per-cell hook (called for each cell)</i>"]
        E["afterRowRender()<br/><i>Per-row hook (after all cells in row)</i>"]
        D --> E
    end

    F["afterRender()<br/><i>Grid-wide DOM manipulation</i>"]
    G["User Interactions<br/><i>onCellClick, onKeyDown, etc.</i>"]
    H["detach()<br/><i>Cleanup when plugin is removed</i>"]

    A --> B --> C --> render --> F --> G --> H

A typical plugin lives in a folder under libs/grid/src/lib/plugins/:

  • Directorymy-feature/
    • my-feature-plugin.ts — Plugin class extending BaseGridPlugin
    • my-feature-plugin.spec.ts — Co-located unit tests
    • my-feature.css — Plugin styles (optional)
    • types.ts — Public config/event types (optional)
    • index.ts — Barrel export
import { BaseGridPlugin, type AfterCellRenderContext } from '@toolbox-web/grid';
// 1. Define your config interface
interface HighlightConfig {
field: string;
threshold: number;
className?: string;
}
// 2. Extend BaseGridPlugin with your config type
export class HighlightPlugin extends BaseGridPlugin<HighlightConfig> {
// Required: unique plugin name
readonly name = 'highlight';
// Optional: CSS styles to inject
override readonly styles = `
.highlight-cell {
background: linear-gradient(135deg, #fff3cd, #ffeeba);
font-weight: 600;
}
`;
// Called for each cell during rendering
override afterCellRender(context: AfterCellRenderContext): void {
const { field, threshold, className = 'highlight-cell' } = this.config;
const { column, value, cellElement } = context;
if (column.field !== field) return;
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue) && numValue > threshold) {
cellElement.classList.add(className);
}
}
}
import { HighlightPlugin } from './highlight-plugin';
grid.gridConfig = {
columns: [...],
plugins: [
new HighlightPlugin({
field: 'salary',
threshold: 100000,
className: 'high-earner',
}),
],
};

Called when the plugin is attached to the grid. Use for initial setup.

import type { GridElement } from '@toolbox-web/grid';
override attach(grid: GridElement): void {
super.attach(grid); // Always call super first!
this.state = new Map();
console.log('Attached to grid with', grid.rows.length, 'rows');
}

Called when the plugin is removed. Clean up event listeners, timers, etc.

override detach(): void {
this.state.clear();
clearInterval(this.timer);
super.detach(); // Always call super last!
}

Transform column definitions before rendering. Return the modified array.

override processColumns(columns: ColumnConfig[]): ColumnConfig[] {
return [
...columns,
{
field: '__rowNumber',
header: '#',
width: 50,
renderer: (ctx) => String(ctx.rowIndex + 1),
},
];
}

Transform row data before rendering. Return the modified array.

override processRows(rows: T[]): T[] {
return rows.filter(row => row.active);
}

Called after each render cycle. Use for grid-wide DOM manipulation.

override afterRender(): void {
const gridEl = this.gridElement;
const rows = gridEl.querySelectorAll('.data-grid-row');
rows.forEach((row, i) => {
if (i % 2 === 0) row.classList.add('even-row');
});
}

Called after each cell is rendered. More efficient than afterRender for per-cell modifications because you receive the cell context directly—no DOM queries needed.

import type { AfterCellRenderContext } from '@toolbox-web/grid';
override afterCellRender(context: AfterCellRenderContext): void {
const { row, rowIndex, colIndex, value, cellElement, column } = context;
// Add selection class without DOM queries
if (this.isSelected(rowIndex, colIndex)) {
cellElement.classList.add('selected');
}
// Add validation error styling
if (this.hasError(row, column.field)) {
cellElement.classList.add('has-error');
}
}

Called after a row is fully rendered (all cells complete). Use for row-level decorations, styling, or ARIA attributes.

import type { AfterRowRenderContext } from '@toolbox-web/grid';
override afterRowRender(context: AfterRowRenderContext): void {
const { row, rowIndex, rowElement } = context;
// Add row selection class without DOM queries
if (this.isRowSelected(rowIndex)) {
rowElement.classList.add('selected', 'row-focus');
}
// Add validation error styling
if (this.rowHasErrors(row)) {
rowElement.classList.add('has-errors');
}
}

BaseGridPlugin provides protected helpers:

HelperDescription
this.gridTyped GridElement (extends GridElementRef)
this.gridElementGrid as HTMLElement for DOM queries
this.columnsCurrent column configurations
this.visibleColumnsOnly visible columns
this.rowsProcessed rows (after filtering, grouping)
this.sourceRowsOriginal unfiltered rows
this.disconnectSignalAbortSignal for auto-cleanup
this.isAnimationEnabledWhether animations are enabled
this.animationDurationAnimation duration in ms
this.gridIconsMerged icon configuration
this.getPluginByName(name)Get another plugin by name (preferred)
this.getPlugin(PluginClass)Get another plugin by class (alternative)
this.emit(eventName, detail)Dispatch a DOM CustomEvent from the grid (external consumers)
this.broadcast(eventType, detail)Emit on the internal event bus + DOM (other plugins + external consumers)
this.on(eventType, callback)Subscribe to event-bus events (auto-cleaned on detach)
this.requestRender()Request full re-render
this.requestAfterRender()Request lightweight style update
this.requestVirtualRefresh()Re-render visible rows without rebuilding the row model
this.setIcon(el, iconKey)Set icon on element using CSS-first hybrid approach

Handle cell click events. Return true to prevent default behavior.

import type { CellClickEvent } from '@toolbox-web/grid';
override onCellClick(event: CellClickEvent): boolean | void {
const { row, field, value, cellEl } = event;
if (field === 'delete') {
this.handleDelete(row);
return true; // Prevent default click handling
}
return false; // Allow normal processing
}

Handle mousedown for drag operations or selection.

import type { CellMouseEvent } from '@toolbox-web/grid';
override onCellMouseDown(event: CellMouseEvent): boolean | void {
const { row, field } = event;
if (field === 'drag-handle') {
this.startDrag(row);
return true;
}
return false;
}

Handle keyboard events. Return true to prevent default.

override onKeyDown(event: KeyboardEvent): boolean | void {
if (event.ctrlKey && event.key === 'd') {
this.duplicateSelectedRow();
return true;
}
return false;
}

Respond to scroll events (use sparingly — can impact performance).

import type { ScrollEvent } from '@toolbox-web/grid';
override onScroll(event: ScrollEvent): void {
this.updateStickyElements(event.scrollTop);
}

Custom row rendering. Return true to skip default rendering.

override renderRow(row: T, rowEl: HTMLElement, rowIndex: number): boolean | void {
if (row.type === 'section-header') {
rowEl.innerHTML = `<div class="section-header">${row.title}</div>`;
return true;
}
return false;
}

Plugins can inject CSS via the styles property (uses adoptedStyleSheets):

// Import CSS as string (Vite)
import styles from './my-plugin.css?inline';
export class MyPlugin extends BaseGridPlugin {
override readonly styles = styles;
}
my-plugin.css
.my-custom-class {
background: #f0f0f0;
border-left: 3px solid #1976d2;
}
override afterRender(): void {
// Access current rows (after processing)
const rows = this.grid.rows;
// Access effective config
const config = this.grid.gridConfig;
// Access sort state
const sortState = this.grid.sortState;
// Access changed rows (editing)
const changes = this.grid.changedRows;
// Request a full re-render (internal API for plugins)
this.grid.requestRender();
// Request only afterRender hooks (lightweight update)
this.grid.requestAfterRender();
}

The Plugin Manifest is a static property that declares metadata about your plugin’s capabilities and requirements. The grid uses this metadata for:

  • Validation: Detect missing plugins when their properties are used
  • Configuration rules: Warn or error on invalid config combinations
  • Query routing: Efficiently route queries only to plugins that handle them
  • Event discovery: Document events that other plugins can subscribe to
  • Incompatibility detection: Warn when conflicting plugins are loaded together
import { BaseGridPlugin, type PluginManifest } from '@toolbox-web/grid';
interface MyPluginConfig {
optionA?: boolean;
optionB?: boolean;
}
export class MyPlugin extends BaseGridPlugin<MyPluginConfig> {
static override readonly manifest: PluginManifest<MyPluginConfig> = {
// Properties this plugin owns (for validation)
ownedProperties: [
{
property: 'myColumnProp',
level: 'column',
description: 'the "myColumnProp" column property',
},
{
property: 'myGridOption',
level: 'config',
description: 'the "myGridOption" grid config option',
},
],
// Configuration validation rules
configRules: [
{
id: 'my-plugin/invalid-combo',
severity: 'warn',
message: 'optionA and optionB cannot both be true',
check: (config) => config.optionA === true && config.optionB === true,
},
],
// Queries this plugin handles
queries: [
{ type: 'getMyState', description: 'Get the current plugin state' },
],
// Events this plugin emits
events: [
{ type: 'my-state-change', description: 'Emitted when state changes' },
],
// Plugins that conflict with this one
incompatibleWith: [
{ name: 'conflictingPlugin', reason: 'Both plugins modify the same DOM elements' },
],
// Hook execution priority (lower = earlier, default 0)
hookPriority: {
processRows: 100, // Run after most plugins
afterRender: -50, // Run before most plugins
},
};
readonly name = 'myPlugin';
}

Declare properties your plugin adds to ColumnConfig or GridConfig. If a user configures these properties without loading your plugin, the grid throws a helpful error:

ownedProperties: [
{
property: 'editable',
level: 'column',
description: 'the "editable" column property',
importHint: "import { EditingPlugin } from '@toolbox-web/grid/plugins/editing';",
},
],

Error shown to user:

[tbw-grid] Configuration error:
Column(s) [name, email] use the "editable" column property, but the required plugin is not loaded.
→ Add the plugin to your gridConfig.plugins array:
import { EditingPlugin } from '@toolbox-web/grid/plugins/editing';
plugins: [new EditingPlugin(), ...]
configRules: [
{
id: 'selection/range-dblclick',
severity: 'warn',
message: '"triggerOn: dblclick" has no effect when mode is "range".',
check: (config) => config.mode === 'range' && config.triggerOn === 'dblclick',
},
],
  • severity: 'error' — Throws an exception (always)
  • severity: 'warn' — Logs to console (development only)

By default, hooks execute in plugin array order. Use hookPriority to override the execution order for specific hooks without changing the plugin array:

static override readonly manifest: PluginManifest = {
hookPriority: {
processRows: 100, // Higher value → runs later
afterRender: -50, // Negative value → runs earlier
},
};
  • Default priority is 0 for all hooks
  • Lower values execute first; higher values execute later
  • Plugins with equal priority preserve their original array order
  • Priority is configured per hook — a plugin can run early for one hook and late for another

Available hooks: processColumns, processRows, afterRender, afterCellRender, afterRowRender, onHeaderClick, onRowClick, onCellClick, onCellMouseDown, onCellMouseMove, onCellMouseUp, onKeyDown, onScroll, onScrollRender.

Declare required plugins via a static dependencies property:

export class UndoRedoPlugin extends BaseGridPlugin<UndoRedoConfig> {
static override readonly dependencies: PluginDependency[] = [
{ name: 'editing', required: true, reason: 'Tracks cell edit history' },
{ name: 'selection', required: false, reason: 'Enables selection-based undo' },
];
readonly name = 'undoRedo';
}

Plugins can emit and subscribe to events using the built-in Event Bus. Events are automatically cleaned up when a plugin is detached.

import { BaseGridPlugin, type PluginManifest } from '@toolbox-web/grid';
// Plugin A: Emit events
export class FilterPlugin extends BaseGridPlugin<FilterConfig> {
readonly name = 'filtering';
// Declare events in manifest for discoverability
static override readonly manifest: PluginManifest = {
events: [
{ type: 'filter-change', description: 'Emitted when filters change' },
],
};
applyFilter(criteria: FilterCriteria): void {
// ... filter logic
// Broadcast to both DOM consumers and other plugins
this.broadcast('filter-change', { criteria, rowCount: this.rows.length });
}
}
// Plugin B: Subscribe to events
export class SelectionPlugin extends BaseGridPlugin<SelectionConfig> {
readonly name = 'selection';
override attach(grid: GridElement): void {
super.attach(grid);
// Subscribe — auto-cleaned on detach
this.on('filter-change', (detail) => {
console.log('Filter changed, clearing selection');
this.clearSelection();
});
}
}

Query System (Synchronous State Retrieval)

Section titled “Query System (Synchronous State Retrieval)”

Plugins can expose queryable state that other plugins can retrieve synchronously.

import { BaseGridPlugin, type PluginManifest, type PluginQuery } from '@toolbox-web/grid';
// Plugin A: Handle queries
export class SelectionPlugin extends BaseGridPlugin<SelectionConfig> {
readonly name = 'selection';
// Declare queries in manifest for routing
static override readonly manifest: PluginManifest = {
queries: [
{ type: 'getSelection', description: 'Get current selection state' },
],
};
override handleQuery(query: PluginQuery): unknown {
if (query.type === 'getSelection') {
return this.getSelection();
}
return undefined;
}
}
// Plugin B: Query other plugins
export class ClipboardPlugin extends BaseGridPlugin<ClipboardConfig> {
readonly name = 'clipboard';
copy(): void {
const responses = this.grid.query<SelectionResult>('getSelection', undefined);
const selection = responses[0];
if (selection?.ranges.length > 0) {
// Copy selected cells
}
}
}

For events that external code should listen to, use emit():

this.emit('copy', { text: copiedText, rowCount: 5 });
// External code:
grid.on('copy', (detail) => console.log(detail));

For type-safe row access, use generics:

export class TypedPlugin<T extends { id: number }> extends BaseGridPlugin<{ idField: keyof T }> {
override processRows(rows: T[]): T[] {
const idField = this.config.idField;
return rows.filter(row => row[idField] != null);
}
}
// Usage
new TypedPlugin<Employee>({ idField: 'employeeId' });

This example adds a synthetic column that doesn’t map to any field in the data. The __rowNumber field doesn’t exist on row objects—the renderer uses ctx.rowIndex instead of ctx.value. This pattern is safe because:

  • processColumns only modifies column definitions, not row data
  • Renderers produce DOM output—they don’t write back to the source data
  • The rows array remains unchanged
import { BaseGridPlugin, type ColumnConfig } from '@toolbox-web/grid';
interface RowNumberConfig {
header?: string;
width?: number;
startFrom?: number;
}
export class RowNumberPlugin extends BaseGridPlugin<RowNumberConfig> {
readonly name = 'rowNumber';
override readonly styles = `
.row-number-cell {
color: #888;
font-size: 0.85em;
text-align: center;
}
`;
override processColumns(columns: ColumnConfig[]): ColumnConfig[] {
const { header = '#', width = 50, startFrom = 1 } = this.config;
return [
{
field: '__rowNumber',
header,
width,
minWidth: 40,
maxWidth: 80,
resizable: false,
sortable: false,
renderer: (ctx) => {
const span = document.createElement('span');
span.className = 'row-number-cell';
span.textContent = String(ctx.rowIndex + startFrom);
return span;
},
},
...columns,
];
}
}
  1. Always call super — In attach() call it first, in detach() call it last
  2. Use unique names — Plugin name should be unique across all plugins
  3. Clean up resources — Remove event listeners and timers in detach()
  4. Return modified dataprocessColumns and processRows must return arrays
  5. Use ctx.signal — For automatic cleanup of event listeners in renderers
  6. Avoid heavy operations — Keep hooks fast, especially onScroll and afterRender
  7. Document your config — Use JSDoc comments for IDE support
  8. Version your plugins — Increment version when making breaking changes
import { describe, it, expect, beforeEach } from 'vitest';
import { RowNumberPlugin } from './row-number-plugin';
describe('RowNumberPlugin', () => {
let plugin: RowNumberPlugin;
beforeEach(() => {
plugin = new RowNumberPlugin({ startFrom: 1 });
});
it('should add row number column', () => {
const columns = plugin.processColumns([
{ field: 'name', header: 'Name' },
]);
expect(columns).toHaveLength(2);
expect(columns[0].field).toBe('__rowNumber');
});
});

Plugins can be promoted to features so consumers can enable them via the declarative features config instead of manually instantiating classes. This also enables shorthand syntax and tree-shaking.

Create a feature file that registers your plugin with a factory function:

// my-feature.ts (e.g., libs/grid/src/lib/features/row-numbers.ts)
import { RowNumberPlugin, type RowNumberConfig } from '../plugins/row-numbers';
import { registerFeature } from './registry';
// Augment the FeatureConfig interface so TypeScript knows about your feature
declare module '../core/types' {
interface FeatureConfig {
/** Enable row numbering. Shorthand: `true` or a start number. */
rowNumbers?: boolean | number | RowNumberConfig;
}
}
// Register the factory — convert shorthands to full plugin config
registerFeature('rowNumbers', (config) => {
if (config === true) {
return new RowNumberPlugin(); // defaults
}
if (typeof config === 'number') {
return new RowNumberPlugin({ startFrom: config }); // shorthand
}
return new RowNumberPlugin(config as RowNumberConfig); // full config
});

Every feature module follows the same three steps:

  1. Import the plugin class and its config type
  2. Augment FeatureConfig via declare module — this adds your shorthand types to the features object
  3. Call registerFeature(name, factory) — the factory receives the raw config value and returns a plugin instance

The factory function is responsible for converting shorthand values (true, 'row', a number, etc.) into the full plugin constructor config.

Feature modules are side-effect imports — importing the file automatically registers the feature:

// Consumer code
import '@toolbox-web/grid/features/row-numbers'; // ← registers the feature
grid.gridConfig = {
features: {
rowNumbers: 5, // shorthand: start from 5
},
};

If no one imports the feature module, it doesn’t exist in the bundle — this is how tree-shaking works.

Shorthand TypeExampleUsage
booleanfiltering: trueEnable with defaults
String literalselection: 'row'Pick a mode
numberrowNumbers: 5Single numeric option
Full configediting: { editOn: 'click', ... }Full control

If your feature depends on other features, declare it in the PLUGIN_DEPENDENCIES map in registry.ts:

// In registry.ts
const PLUGIN_DEPENDENCIES: Record<string, string[]> = {
clipboard: ['selection'], // clipboard needs selection
rowNumbers: [], // no dependencies
};

The grid reorders plugins automatically based on declared dependencies and warns if a dependency is missing.

For the feature to work with framework adapters (React, Vue, Angular), each adapter needs to re-export or reference the feature. The typical pattern:

libs/grid-react/src/features/row-numbers.ts
import '@toolbox-web/grid/features/row-numbers';

This ensures the side-effect import is available when consumers import from the adapter package:

import '@toolbox-web/grid-react/features/row-numbers';
<DataGrid rows={rows} gridConfig={{ features: { rowNumbers: true } }} />
AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://raw.githubusercontent.com/OysteinAmundsen/toolbox/main/llms-full.txt