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.

override attach(grid: DataGridElement): 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-row-index]');
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 GridElementRef with all plugin APIs
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 custom event from grid
this.requestRender()Request full re-render
this.requestAfterRender()Request lightweight style update
this.resolveIcon(name)Get icon value by name
this.setIcon(el, icon)Set icon on element

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

override onCellClick(event: CustomEvent): boolean {
const { row, field, value, cellElement } = event.detail;
if (field === 'delete') {
this.handleDelete(row);
return true; // Prevent default click handling
}
return false; // Allow normal processing
}

Handle mousedown for drag operations or selection.

override onCellMouseDown(event: CustomEvent): boolean {
const { row, field } = event.detail;
if (field === 'drag-handle') {
this.startDrag(row);
return true;
}
return false;
}

Handle keyboard events. Return true to prevent default.

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

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

override onScroll(event: Event): void {
const scrollTop = (event.target as HTMLElement).scrollTop;
this.updateStickyElements(scrollTop);
}

Custom row rendering. Return true to skip default rendering.

override renderRow(row: T, rowEl: HTMLElement, rowIndex: number): boolean {
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' },
],
};
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)

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-applied', description: 'Emitted when filters change' },
],
};
applyFilter(criteria: FilterCriteria): void {
// ... filter logic
// Emit to other plugins (NOT a DOM event)
this.emitPluginEvent('filter-applied', { 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-applied', (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