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.
Plugin Architecture
Section titled “Plugin Architecture”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
Basic Plugin Structure
Section titled “Basic Plugin Structure”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
- my-feature-plugin.ts — Plugin class extending
import { BaseGridPlugin, type AfterCellRenderContext } from '@toolbox-web/grid';
// 1. Define your config interfaceinterface HighlightConfig { field: string; threshold: number; className?: string;}
// 2. Extend BaseGridPlugin with your config typeexport 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); } }}Using Your Plugin
Section titled “Using Your Plugin”import { HighlightPlugin } from './highlight-plugin';
grid.gridConfig = { columns: [...], plugins: [ new HighlightPlugin({ field: 'salary', threshold: 100000, className: 'high-earner', }), ],};Lifecycle Hooks
Section titled “Lifecycle Hooks”attach(grid)
Section titled “attach(grid)”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');}detach()
Section titled “detach()”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!}processColumns(columns)
Section titled “processColumns(columns)”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), }, ];}processRows(rows)
Section titled “processRows(rows)”Transform row data before rendering. Return the modified array.
override processRows(rows: T[]): T[] { return rows.filter(row => row.active);}afterRender()
Section titled “afterRender()”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'); });}afterCellRender(context)
Section titled “afterCellRender(context)”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'); }}afterRowRender(context)
Section titled “afterRowRender(context)”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'); }}Built-in Plugin Helpers
Section titled “Built-in Plugin Helpers”BaseGridPlugin provides protected helpers:
| Helper | Description |
|---|---|
this.grid | Typed GridElementRef with all plugin APIs |
this.gridElement | Grid as HTMLElement for DOM queries |
this.columns | Current column configurations |
this.visibleColumns | Only visible columns |
this.rows | Processed rows (after filtering, grouping) |
this.sourceRows | Original unfiltered rows |
this.disconnectSignal | AbortSignal for auto-cleanup |
this.isAnimationEnabled | Whether animations are enabled |
this.animationDuration | Animation duration in ms |
this.gridIcons | Merged 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 |
Event Hooks
Section titled “Event Hooks”onCellClick(event)
Section titled “onCellClick(event)”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}onCellMouseDown(event)
Section titled “onCellMouseDown(event)”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;}onKeyDown(event)
Section titled “onKeyDown(event)”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;}onScroll(event)
Section titled “onScroll(event)”Respond to scroll events (use sparingly — can impact performance).
override onScroll(event: Event): void { const scrollTop = (event.target as HTMLElement).scrollTop; this.updateStickyElements(scrollTop);}renderRow(row, rowEl, rowIndex)
Section titled “renderRow(row, rowEl, rowIndex)”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;}Injecting Styles
Section titled “Injecting Styles”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-custom-class { background: #f0f0f0; border-left: 3px solid #1976d2;}export class MyPlugin extends BaseGridPlugin { override readonly styles = ` .my-custom-class { background: #f0f0f0; border-left: 3px solid #1976d2; }
.my-custom-class:hover { background: #e0e0e0; } `;}Accessing Grid State
Section titled “Accessing Grid State”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();}Plugin Manifest
Section titled “Plugin Manifest”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
Manifest Structure
Section titled “Manifest Structure”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';}Owned Properties
Section titled “Owned Properties”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(), ...]Configuration Rules
Section titled “Configuration Rules”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)
Plugin Dependencies
Section titled “Plugin Dependencies”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';}Plugin Communication
Section titled “Plugin Communication”Event Bus (Plugin-to-Plugin)
Section titled “Event Bus (Plugin-to-Plugin)”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 eventsexport 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 eventsexport 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 queriesexport 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 pluginsexport 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 } }}DOM Events (External Consumers)
Section titled “DOM Events (External Consumers)”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));TypeScript Generics
Section titled “TypeScript Generics”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); }}
// Usagenew TypedPlugin<Employee>({ idField: 'employeeId' });Complete Example: Row Numbering Plugin
Section titled “Complete Example: Row Numbering Plugin”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:
processColumnsonly modifies column definitions, not row data- Renderers produce DOM output—they don’t write back to the source data
- The
rowsarray 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, ]; }}Best Practices
Section titled “Best Practices”- Always call
super— Inattach()call it first, indetach()call it last - Use unique names — Plugin
nameshould be unique across all plugins - Clean up resources — Remove event listeners and timers in
detach() - Return modified data —
processColumnsandprocessRowsmust return arrays - Use
ctx.signal— For automatic cleanup of event listeners in renderers - Avoid heavy operations — Keep hooks fast, especially
onScrollandafterRender - Document your config — Use JSDoc comments for IDE support
- Version your plugins — Increment version when making breaking changes
Testing Plugins
Section titled “Testing Plugins”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'); });});Registering as a Feature
Section titled “Registering as a Feature”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.
1. Create the Feature Module
Section titled “1. Create the Feature Module”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 featuredeclare 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 configregisterFeature('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});2. The Pattern
Section titled “2. The Pattern”Every feature module follows the same three steps:
- Import the plugin class and its config type
- Augment
FeatureConfigviadeclare module— this adds your shorthand types to thefeaturesobject - 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.
3. How It Works at Runtime
Section titled “3. How It Works at Runtime”Feature modules are side-effect imports — importing the file automatically registers the feature:
// Consumer codeimport '@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.
4. Common Shorthand Patterns
Section titled “4. Common Shorthand Patterns”| Shorthand Type | Example | Usage |
|---|---|---|
boolean | filtering: true | Enable with defaults |
| String literal | selection: 'row' | Pick a mode |
number | rowNumbers: 5 | Single numeric option |
| Full config | editing: { editOn: 'click', ... } | Full control |
5. Declaring Dependencies
Section titled “5. Declaring Dependencies”If your feature depends on other features, declare it in the PLUGIN_DEPENDENCIES map in registry.ts:
// In registry.tsconst 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.
6. Framework Adapter Integration
Section titled “6. Framework Adapter Integration”For the feature to work with framework adapters (React, Vue, Angular), each adapter needs to re-export or reference the feature. The typical pattern:
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 } }} />