| name | handsontable-plugin-dev |
| description | Use when creating a new Handsontable plugin, modifying an existing plugin's behavior, adding hooks or options to a plugin, or working with the plugin lifecycle (enablePlugin, disablePlugin, updatePlugin). Covers the full plugin contract, conflict registration, settings validation, and IndexMapper integration. |
Plugin File Structure
src/plugins/{pluginName}/
āāā index.js # Re-exports PLUGIN_KEY, PLUGIN_PRIORITY, ClassName
āāā {pluginName}.js # Main class extending BasePlugin
āāā __tests__/ # Tests (*.spec.js for E2E, *.unit.js for unit)
āāā {submodules}/ # Additional files (UI classes, strategies, etc.)
Required Static Properties
| Property | Purpose | Example |
|---|
PLUGIN_KEY | Unique camelCase identifier | 'pagination' |
PLUGIN_PRIORITY | Execution order (higher = later) | 900 |
SETTING_KEYS | Options triggering updatePlugin | ['pagination'], true (always), false (never) |
PLUGIN_DEPS | Required plugins/types | ['plugin:AutoRowSize'] |
DEFAULT_SETTINGS | Defaults for this.getSetting() | { pageSize: 10 } |
SETTINGS_VALIDATORS | Validate settings (object map or single fn) | { pageSize: v => v > 0 } |
Lifecycle Methods (in order)
isEnabled()
enablePlugin()
updatePlugin()
disablePlugin()
destroy()
Key Patterns (from Pagination gold standard)
Private fields -- Use # prefix for all internal state. No @private JSDoc.
Hook callbacks -- Arrow function class fields so removeLocalHook works:
#onIndexCacheUpdate = () => {
if (!this.#internalCall && this.hot?.view) {
this.#recompute();
}
};
Hook registration -- this.addHook() auto-cleans on disablePlugin(). this.hot.addHook() does NOT.
Register new hook names at module level:
import Hooks from '../../core/hooks';
Hooks.getSingleton().register('beforeMyAction');
Settings -- Read via this.getSetting('key') (supports dot notation). Defaults come from DEFAULT_SETTINGS.
Conflict registration -- At module level, before the class:
import { registerConflict } from '../base/conflictRegistry';
registerConflict(PLUGIN_KEY, ['nestedRows', 'mergeCells']);
Check in enablePlugin() with this.isHardConflictBlocked().
IndexMapper -- Create maps in enablePlugin(), unregister in disablePlugin():
this.#map = this.hot.rowIndexMapper.createAndRegisterIndexMap(this.pluginName, 'hiding', false);
UI separation -- Extract UI into its own class with dependency injection (no direct hot reference).
Strategy pattern -- Use for swappable logic (e.g., autoPageSize vs fixedPageSize).
Batch rendering -- When making multiple data/render changes, wrap them to avoid redundant render cycles:
this.hot.batch(() => {
});
this.hot.suspendRender();
this.hot.resumeRender();
Decoupling Rules
- No direct cross-plugin imports. Use hooks or
hot.getPlugin('{Name}').
- No circular dependencies between plugins.
- Conflict ownership: the plugin introducing the incompatibility owns the blocking logic.
- DataProvider built-in errors -- The DataProvider plugin surfaces request failures through
getPlugin('notification') when notification is enabled (error toasts). Fetch failures include a primary Refetch action and duration: 0 so the user can retry fetchData() from the toast. It does not use Dialog for that path. Dialog is still used elsewhere (for example Loading plugin, ExportFile overlay). Prefer hooks (afterDataProviderFetchError, afterRowsMutationError) for fully custom error UI when Notification is off.
Registration Checklist
- Plugin's
index.js: export { PLUGIN_KEY, PLUGIN_PRIORITY, ClassName } from './pluginName';
- Wire into
src/plugins/index.js.
- Add default option (disabled) in
src/dataMap/metaManager/metaSchema.js.
- Add TypeScript definitions in
types/.
Focus Management
If your plugin provides UI elements (buttons, inputs, navigation bars), you must integrate with the focus manager (src/focusManager/).
- Register a focus scope with a unique name for your plugin's UI region.
- Implement focus entry logic -- when the scope is activated, focus the first or last focusable element depending on the navigation direction (Tab = first, Shift+Tab = last).
- The focus manager listens to Tab/Shift+Tab keyboard events and blocks or allows them to ensure the correct UI module is focused during normal focus navigation.
- Scopes switch automatically based on which element the user clicks or focuses. The Core switches the active scope and sets the listen mode so the user can interact with either the grid or another module (e.g., pagination bar).
- See the Pagination plugin for a reference implementation (
#registerFocusScope / #unregisterFocusScope).
Important Gotchas
- Merged cells -- read from meta, not DOM: When working with merged cells, read
colspan/rowspan from hot.getCellMeta(row, col) (set by MergeCells via afterGetCellMeta), not from DOM element attributes. The meta is authoritative and always available regardless of viewport state.
Testing Requirements
- E2E tests (
__tests__/*.spec.js): all it() callbacks must be async.
- Unit tests (
__tests__/*.unit.js): test strategies and helpers in isolation.
- Test
updateSettings(), enablePlugin()/disablePlugin() toggling.
- Test interactions with other plugins (sorting, filters, hidden rows).
Gold standard: src/plugins/pagination/pagination.js. Base class: src/plugins/base/base.js.
See .ai/ARCHITECTURE.md and .ai/CONVENTIONS.md for deeper context.