name: new-plugin
description: Create a new grid plugin for @toolbox-web/grid following the canonical plugin structure. Use when adding a plugin or extending the grid with optional, toggleable behaviour (hooks, styles, tests, and documentation). Prefer a plugin over a core feature to keep the bundle lean.
argument-hint: [description]
Create a New Grid Plugin
Follow this step-by-step workflow to create a new plugin for @toolbox-web/grid.
1. Scaffold the File Structure
Create the plugin directory under libs/grid/src/lib/plugins/<plugin-name>/ with these files:
libs/grid/src/lib/plugins/<plugin-name>/
├── index.ts # Barrel exports
├── <PluginName>Plugin.ts # Main plugin class
├── <plugin-name>.css # Styles (imported via Vite ?inline)
├── types.ts # Config and exported types
├── <plugin-name>.ts # Pure helper functions (optional)
├── <plugin-name>.spec.ts # Unit tests
└── README.md # Package-level docs (optional)
2. Define Types (types.ts)
/**
* Configuration for the <PluginName> plugin.
*/
export interface <PluginName>Config {
// Add config options here
}
3. Implement the Plugin Class (<PluginName>Plugin.ts)
import { BaseGridPlugin, type GridElementRef, type PluginManifest } from '@toolbox-web/grid';
import type { <PluginName>Config } from './types';
import styles from './<plugin-name>.css?inline';
export class <PluginName>Plugin extends BaseGridPlugin<<PluginName>Config> {
readonly name = '<pluginName>'; // camelCase
readonly version = '1.0.0';
override readonly styles = styles;
// Declare manifest for validation and metadata
static override readonly manifest: PluginManifest<<PluginName>Config> = {
ownedProperties: [
// { property: 'myProp', level: 'column' },
],
configRules: [],
};
// Optional: declare dependencies
// static override readonly dependencies: PluginDependency[] = [
// { name: 'selection', required: false, reason: 'Enhances selection behavior' },
// ];
override attach(grid: GridElementRef): void {
super.attach(grid);
// Initialize plugin state, add event listeners using this.disconnectSignal
}
override detach(): void {
// Cleanup (listeners auto-removed via disconnectSignal)
super.detach();
}
// Override hooks as needed:
// processColumns?(columns): ColumnConfig[]
// processRows?(rows): unknown[]
// afterRender?(): void
// onScroll?(event): void
// onCellClick?(event): void
// onKeyDown?(event): boolean
// renderRow?(row, rowEl, rowIndex): boolean
// handleQuery?(query): unknown
}
4. Create Barrel Export (index.ts)
export { <PluginName>Plugin } from './<PluginName>Plugin';
export type { <PluginName>Config } from './types';
5. Register the Plugin Entry Point
Make the plugin discoverable as both an individual entry point and via the all-plugins barrel:
libs/grid/vite.config.ts— Add a new key to theentrymap of the form'plugins/<plugin-name>': resolve(__dirname, 'src/lib/plugins/<plugin-name>/index.ts'). This produces a tree-shakeable subpath import (@toolbox-web/grid/plugins/<plugin-name>).libs/grid/src/all.ts— Addexport * from './lib/plugins/<plugin-name>';so consumers who import from@toolbox-web/grid/allreceive the new plugin alongside every other built-in plugin.
6. Register the Feature Module (mandatory)
A plugin is not complete without a corresponding feature module. Features are the high-level, declarative API surface (gridConfig.features.<name>) that lets consumers enable a plugin with a single config line and a side-effect import, instead of constructing the plugin class manually.
6.1 Core feature module
Create libs/grid/src/lib/features/<plugin-name>.ts:
/**
* <PluginName> feature for @toolbox-web/grid
*
* @example
* ```typescript
* import '@toolbox-web/grid/features/<plugin-name>';
*
* grid.gridConfig = { features: { <pluginName>: true } };
* ```
*/
import { <PluginName>Plugin, type <PluginName>Config } from '../plugins/<plugin-name>';
import { registerFeature } from './registry';
declare module '../core/types' {
interface FeatureConfig<TRow> {
/** One-line description of what enabling this feature does. */
<pluginName>?: boolean | <PluginName>Config;
}
}
registerFeature('<pluginName>', (config) => {
if (config === true) return new <PluginName>Plugin();
return new <PluginName>Plugin(typeof config === 'object' ? config : undefined);
});
/** @internal Type anchor — forces bundlers to preserve this module's FeatureConfig augmentation when re-exported. */
export type _Augmentation = true;
Then wire it up:
libs/grid/vite.config.ts— Add'features/<plugin-name>': resolve(__dirname, 'src/lib/features/<plugin-name>.ts')to theentrymap.libs/grid/src/lib/features/index.ts(if a barrel exists) — Re-export the new feature module so@toolbox-web/grid/featurespulls it in alongside the others.libs/grid/src/lib/features/registry.spec.ts— Add a test confirming the feature factory returns a<PluginName>Plugininstance for both the boolean and object config shapes.
6.2 Adapter feature modules (Angular / React / Vue)
For each adapter, create a thin re-export module so consumers get the same declarative surface from the framework package:
libs/grid-react/src/features/<plugin-name>.tslibs/grid-vue/src/features/<plugin-name>.tslibs/grid-angular/src/features/<plugin-name>.ts
Each file should:
- Import the core feature module for its side effect:
import '@toolbox-web/grid/features/<plugin-name>'; - Optionally export a framework-idiomatic helper (React hook
use<PluginName>(), Vue composableuse<PluginName>(), Angular service/directive) if the plugin exposes a programmatic API worth surfacing. - Be added to the adapter's
vite.config.mtsentrymap at'features/<plugin-name>', the adapter'spackage.jsonexportsmap (already covered by the wildcard./features/*entry in existing adapters), and thefeatures/index.tsbarrel.
Use libs/grid-react/src/features/selection.ts (and the matching Vue / Angular files) as the reference shape.
7. Framework Adapter Integration for DOM Template Inputs (conditional)
This step is mandatory if the plugin accepts any kind of user-supplied DOM template, renderer function, HTML string, or element factory — e.g. a custom cell/header/filter renderer, a custom editor, a tool-panel body, a master-detail panel, a tooltip body, a context-menu item template, an empty-state template, etc. Skip this step only if the plugin is purely behavioral (no template inputs).
Why this is required: vanilla <tbw-grid> consumers pass DOM strings or callbacks that produce raw HTMLElements. Angular / React / Vue users expect to pass TemplateRef, JSX, or <template #slot> instead. Each adapter must translate the framework idiom into a plain DOM callback before forwarding to the plugin.
For each adapter (libs/grid-{angular,react,vue}/):
- Extend the adapter's column / config types so the new template input accepts the framework primitive (e.g. add
<pluginName>Template?: TemplateRef<...>toAngularColumnConfig,<pluginName>Renderer?: (ctx) => ReactNodetoReactColumnConfig, or a slot name toVueColumnConfig). - Update the adapter implementation (
{framework}-grid-adapter.ts) so itscreateRenderer/createEditor/createToolPanelRenderer/ equivalent factory recognizes the new template input and mounts/unmounts the framework component into the DOM element the plugin hands it. - Update the wrapper component / declarative children (
DataGrid.{tsx,vue},Griddirective,<GridColumn>etc.) so users can declare the template inline using framework-native syntax (JSX,<template #slotname>,*tbwXxxstructural directive). - Add adapter tests mirroring the core plugin's behavior tests but using framework components as the template input.
- Update the adapter MDX docs and adapter README with a usage example for the new template input.
See the new-adapter-feature skill for the full per-framework pattern, including which files to touch and the canonical examples to copy.
8. Write Unit Tests (<plugin-name>.spec.ts)
Follow the mock grid pattern used by other plugins:
import { describe, expect, it, vi } from 'vitest';
import { <PluginName>Plugin } from './<PluginName>Plugin';
function createGridMock(/* options */) {
return {
rows: [],
sourceRows: [],
columns: [],
_visibleColumns: [],
effectiveConfig: {},
gridConfig: {},
getPlugin: () => undefined,
query: () => [],
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
requestRender: vi.fn(),
children: [document.createElement('div')],
querySelectorAll: () => [],
querySelector: () => null,
clientWidth: 800,
classList: { add: vi.fn(), remove: vi.fn() },
};
}
describe('<PluginName>Plugin', () => {
it('should have correct name', () => {
const plugin = new <PluginName>Plugin();
expect(plugin.name).toBe('<pluginName>');
});
it('should attach and detach cleanly', () => {
const plugin = new <PluginName>Plugin();
const grid = createGridMock();
plugin.attach(grid as any);
plugin.detach();
});
});
9. Create Styles (<plugin-name>.css)
Use .dg- prefixed class names for grid internals, or plugin-specific class names.
10. Add Accessibility Announcements
If the plugin changes user-visible state (sorting, filtering, selection, editing, expanding/collapsing), announce it via the aria live region:
import { announce, getA11yMessage } from '../../core/internal/aria';
// In the relevant handler:
announce(this.gridElement, getA11yMessage(this.gridElement, 'messageKey', ...args));
- Add a corresponding message function to
A11yMessagesintypes.tswith an English default inDEFAULT_A11Y_MESSAGES getA11yMessage()resolves user-provided i18n overrides fromgridConfig.a11y.messages, falling back to the defaultannounce()respectsgridConfig.a11y.announcements === false(opt-out)- For high-frequency events (e.g., selection changes), debounce the announcement with
setTimeout(~150ms) - Guard for
this.gridElementbeingundefinedin tests —announce()handles this with a null guard
11. Create Demo Component (<PluginName>DefaultDemo.astro)
Create an interactive Astro demo in apps/docs/src/components/demos/<plugin-name>/. See the astro-demo skill for full templates.
12. Create Documentation (<plugin-name>.mdx)
Create a plugin MDX page at apps/docs/src/content/docs/grid/plugins/<plugin-name>.mdx. Import the demo component and wrap it in <ShowSource>. See the docs-update skill for templates.
13. Verify Documentation Build
Build the docs site to verify the new plugin page renders correctly:
bun nx build docs
Navigate to http://localhost:4400/grid/plugins/<plugin-name>/ after running bun nx serve docs.
Plugin API Reference
Use this section as a lookup, not a checklist. The 13 numbered steps above are the actual workflow for creating a plugin. The subsections below catalogue every helper, hook, event-bus method, query-system primitive, dependency rule, manifest field, and styling convention. Jump to the subsection that matches the question you have right now (e.g. "which
emit*method should I use?" → Event Bus & Communication Channels); do not try to memorize the whole reference before writing code.Subsection map:
- Built-in Plugin Helpers \u2014 Properties and helper methods exposed by
BaseGridPlugin.- Plugin Hooks (Class Methods) \u2014 Lifecycle and event hooks you can override.
- Event Bus & Communication Channels \u2014
emitvsemitPluginEventvsbroadcastand the subscription API.- Query System (Synchronous State Retrieval) \u2014 Declaring and handling
queryrequests between plugins.- Plugin Dependencies \u2014 Required vs optional dependencies and how to handle missing ones.
- Plugin Incompatibilities \u2014 Declaring
incompatibleWithin the manifest.- Plugin Manifest System \u2014 The full
manifestschema (events, queries, options, conflicts).
Built-in Plugin Helpers
BaseGridPlugin provides these protected helpers — use them instead of type casting:
| Helper | Description |
|---|---|
this.grid |
Typed GridElementRef with all plugin APIs |
this.gridElement |
Grid as HTMLElement for DOM queries (preferred) |
this.columns |
Current column configurations |
this.visibleColumns |
Only visible columns (for rendering) |
this.rows |
Processed rows (after filtering, grouping) |
this.sourceRows |
Original unfiltered rows |
this.disconnectSignal |
AbortSignal for auto-cleanup of event listeners |
this.isAnimationEnabled |
Whether grid animations are enabled |
this.animationDuration |
Animation duration in ms (default: 200) |
this.gridIcons |
Merged icon configuration |
this.getPluginByName(name) |
Get another plugin instance by name (preferred) |
this.getPlugin(PluginClass) |
Get another plugin instance by class (alternative) |
this.emit(eventName, detail) |
Dispatch custom event from grid (DOM consumers) |
this.emitPluginEvent(type, detail) |
Dispatch to plugin event bus (other plugins) |
this.broadcast(type, detail) |
Dispatch to BOTH plugin bus AND DOM consumers |
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 (string or SVG) |
Note: The grid uses light DOM. Use
this.gridElementfor all DOM queries.
Plugin Hooks (Class Methods)
Override these methods (implement only what's needed):
attach(grid)— Called when attached; callsuper.attach(grid)firstdetach()— Called when removed; cleanup listeners, timers, etc.processColumns(columns)— Transform column definitions; return modified arrayprocessRows(rows)— Transform row data; return modified arrayafterRender()— DOM manipulation after grid rendersonScroll(event)— Handle scroll eventsonCellClick(event)— Handle cell click eventsonCellMouseDown(event)— Handle cell mousedown; returntrueto prevent defaultonKeyDown(event)— Handle keyboard events; returntrueto prevent defaultrenderRow(row, rowEl, rowIndex)— Custom row rendering; returntrueto skip defaulthandleQuery(query)— Handle incoming queries from other plugins
Event Bus & Communication Channels
Plugins have three emission methods. Choosing the wrong one causes silent bugs (other plugins or consumers don't hear events).
| Method | Audience | Use when |
|---|---|---|
this.emit(eventType, detail) |
External consumers | Consumer-facing events with no plugin subscribers (rare) |
this.emitPluginEvent(eventType, detail) |
Other plugins only | Plugin-internal notifications (e.g., filter-change state sync) |
this.broadcast(eventType, detail) |
Consumers AND plugins | Events that both plugins AND consumers need (e.g., sort-change) |
Decision tree:
- Do other plugins need to react? (e.g., Selection clearing on sort) → Yes: use
broadcast()oremitPluginEvent() - Do external
addEventListenerconsumers need this event? → Yes: usebroadcast()oremit() - Both audiences →
broadcast()(most state-change events fall here)
// Subscribing (in attach)
this.on('filter-change', (detail) => { /* handle */ });
// Plugin-only notification
this.emitPluginEvent('filter-change', { field: 'name', value: 'Alice' });
// Both plugin bus AND DOM (most common for state changes)
this.broadcast('sort-change', { sortModel: [...this.sortModel] });
// Declare in manifest
static override readonly manifest: PluginManifest = {
events: [{ type: 'filter-change', description: 'Emitted when filter criteria change' }],
};
| Method | Description |
|---|---|
this.on(eventType, callback) |
Subscribe (auto-cleaned on detach) |
this.off(eventType) |
Unsubscribe |
this.emitPluginEvent(eventType, detail) |
Emit to subscribed plugins only |
this.broadcast(eventType, detail) |
Emit to BOTH plugin bus AND DOM consumers |
Query System (Synchronous State Retrieval)
Plugins expose queryable state. PluginManager uses manifest-based routing.
// Declare in manifest
static override readonly manifest: PluginManifest = {
queries: [{ type: 'canMoveColumn', description: 'Check if column can be moved' }],
};
// Handle
override handleQuery(query: PluginQuery): unknown {
if (query.type === 'canMoveColumn') {
return !(query.context as ColumnConfig).pinned;
}
return undefined;
}
// Query from another plugin
const responses = this.grid.query<boolean>('canMoveColumn', column);
Plugin Dependencies
static override readonly dependencies: PluginDependency[] = [
{ name: 'editing', required: true, reason: 'Tracks edit history' },
{ name: 'selection', required: false, reason: 'Enables advanced selection' },
];
Dependencies must be loaded before the dependent plugin in the plugins array.
Handling missing or failed dependencies:
- A
required: truedependency that is absent at attach time is a hard failure: throw anErrorfromattach()(aftersuper.attach(grid)) with a message naming both the dependent plugin and the missing dependency, e.g.throw new Error('UndoRedoPlugin requires EditingPlugin to be registered before it.');. Do not register hooks or event listeners before the throw — the plugin must abort cleanly so PluginManager can surface the error. - A
required: falsedependency that is absent is a soft degradation: skip the optional integration code path, log a singleconsole.warnfromattach()describing which capability is disabled, and continue attaching normally. Never throw for an optional dependency. - Use
this.getPluginByName(name)(preferred) orthis.getPlugin(PluginClass)to look up a dependency. Both returnundefinedwhen the dependency is not present — always null-check the result before calling methods on it. - Do not attempt to lazy-load or re-attach a missing dependency; PluginManager owns the lifecycle and ordering.
Built-in dependencies:
| Plugin | Depends On | Required |
|---|---|---|
UndoRedoPlugin |
EditingPlugin |
Yes |
ClipboardPlugin |
SelectionPlugin |
Yes |
VisibilityPlugin |
ReorderPlugin |
No |
Plugin Incompatibilities
static override readonly manifest: PluginManifest = {
incompatibleWith: [
{ name: 'groupingRows', reason: 'Card layout does not support row grouping' },
],
};
Plugin Manifest System
The manifest provides declarative validation and metadata:
static override readonly manifest: PluginManifest<MyConfig> = {
ownedProperties: [
{ property: 'myProp', level: 'column' },
{ property: 'globalSetting', level: 'config' },
],
configRules: [{
id: 'myPlugin/invalid-combo',
severity: 'warn', // 'warn' logs, 'error' throws
message: 'optionA and optionB cannot both be true',
check: (config) => config.optionA && config.optionB,
}],
};
Adding plugin-owned properties:
- Always: Add to
manifest.ownedProperties - Optionally: Add to
KNOWN_COLUMN_PROPERTIES/KNOWN_CONFIG_PROPERTIESinvalidate-config.tsfor "forgot to add plugin" detection
Runtime Configuration Validation
The grid validates plugin-owned properties and throws helpful errors if plugins are missing:
| Property | Required Plugin | Level |
|---|---|---|
editable |
EditingPlugin |
Column |
editor |
EditingPlugin |
Column |
editorParams |
EditingPlugin |
Column |
group |
GroupingColumnsPlugin |
Column |
pinned |
PinnedColumnsPlugin |
Column |
sticky |
PinnedColumnsPlugin |
Column |
columnGroups |
GroupingColumnsPlugin |
Config |
Using Plugins
// Individual imports (smaller bundles)
import { SelectionPlugin } from '@toolbox-web/grid/plugins/selection';
// All-in-one bundle
import { SelectionPlugin, FilteringPlugin } from '@toolbox-web/grid/all';
// Configuration
grid.gridConfig = {
plugins: [new SelectionPlugin({ mode: 'row' })],
};
// Access at runtime — preferred (type-safe, no import needed)
const sel = grid.getPluginByName('selection');
sel?.selectAll();
// Alternative — access by class (requires import)
const sel2 = grid.getPlugin(SelectionPlugin);
Always prefer getPluginByName() over getPlugin(). It avoids importing the plugin class and returns the actual instance registered in the grid.
Key Rules
- Use
this.gridElementfor DOM queries (light DOM, no Shadow DOM) - Use
this.gridElement.children[0]for root container (not hardcoded selectors) - Use
this.disconnectSignalfor event listener cleanup - Use
registerStyles()not<style>elements (they get wiped byreplaceChildren()) - Use
this.#scheduler.requestPhase()notrequestAnimationFramefor rendering - Import CSS with
?inlinequery for Vite - Keep files under ~2000 lines
- Export public types from
src/public.ts - Add plugin-owned properties to manifest
ownedProperties - Dev-only warnings: Config rule warnings (severity
'warn') only show in dev environments