Skip to content

Editing Plugin

The Editing plugin enables inline cell editing in the grid. It provides built-in editors for common data types and supports custom editor functions for specialized input scenarios.

Editing is delivered as a plugin rather than built into the core grid for several reasons:

  • Smaller bundle size — Applications that only display data don’t pay for editing code
  • Clear intent — Explicit plugin registration makes editing capability obvious in code
  • Runtime validation — Using editable: true without the plugin throws a helpful error
import '@toolbox-web/grid/features/editing';

Enable the editing feature to use editable and editor column properties:

import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');
grid.gridConfig = {
columns: [
{ field: 'id', header: 'ID' },
{ field: 'name', header: 'Name', editable: true },
{ field: 'price', header: 'Price', type: 'number', editable: true },
{ field: 'active', header: 'Active', type: 'boolean', editable: true },
],
features: { editing: 'dblclick' }, // or 'click'
};
// Listen for commits
grid.on('cell-commit', (detail) => {
console.log('Cell edited:', detail);
});

Double-click any cell to start editing. Press Enter to commit or Escape to cancel.

To dynamically add or remove rows from the grid, simply assign a new array to the rows property. The grid reactively updates to display the new data. Use a custom renderer in an “Actions” column to provide a delete button for each row.

To update individual row values without reassigning the entire rows array, use the Row Update API:

// Update a single row by ID
grid.updateRow('emp-123', { status: 'active', salary: 75000 });
// Batch update multiple rows
grid.updateRows([
{ id: 'emp-123', changes: { status: 'active' } },
{ id: 'emp-456', changes: { department: 'Engineering' } },
]);
// Listen for programmatic updates
grid.on('cell-change', ({ rowId, changes }) => {
console.log('Row updated:', rowId, changes);
});

See the API Reference for full documentation.

Configure how editing is triggered with the editOn option:

features: { editing: 'dblclick' } // or 'click', 'manual'
ValueBehavior
'click'Single click on cell enters edit mode
'dblclick'Double-click on cell enters edit mode (default)
'manual'Programmatic only via beginCellEdit(rowIndex, field)

The demo above uses editOn: 'click' — a single click enters edit mode.

KeyBehavior
EnterStart editing the entire row (all editable cells get editors)
F2Start editing only the focused cell (single-cell edit)
EscapeCancel the current edit and revert changes
Tab / Shift+TabMove to next/previous editable cell
Arrow Up/DownCommit current edit, exit edit mode, and move to adjacent row
SpaceToggle boolean cells (when not in edit mode)

For data entry forms or spreadsheet-like interfaces, use mode: 'grid' to make all editable cells show their editors at all times:

features: { editing: { mode: 'grid' } }
ModeBehavior
'row'Default. Click/dblclick to enter edit mode, Escape to exit
'grid'All editors visible immediately. Excel-like navigation.

In grid mode:

  • All editable: true cells render with their editors on load
  • Tab/Shift+Tab navigates between editable cells (wraps to next/prev row)
  • cell-commit events fire normally when values change
Grid Mode: All editable cells show editors immediately.
  • Tab/Shift+Tab: Move between editable cells
  • Enter: Commit current cell value
  • Click outside or press Escape: No effect (always in edit mode)
Recent Changes:

Grid mode supports Excel-style keyboard interaction with two modes:

StateHow to EnterBehavior
NavigationPress EscapeArrow keys move between cells. Input is blurred.
EditPress Enter, click input, or start typingArrow keys work within the input (cursor position, up/down for numbers).
  • Escape: Blurs the current input, switching to navigation mode where arrow keys move the cell focus
  • Enter: Focuses the current cell’s input, switching to edit mode
  • Click: Clicking directly on an input naturally focuses it (edit mode)
  • Arrow Keys: In navigation mode, use arrow keys to move between cells. In edit mode, arrows work within the input (e.g., moving cursor in text, incrementing numbers)

This mimics Excel/Google Sheets behavior where you can quickly navigate a grid with arrows, then press Enter to edit a cell.

The grid provides appropriate editors based on column type:

Column TypeEditor
stringText input
numberNumber input with validation
booleanCheckbox
dateDate picker input
selectDropdown (requires options array)

Configure built-in editors using the editorParams property. This allows you to set constraints and attributes without creating a custom editor.

Double-click cells to edit. Each column uses editorParams to customize the editor — min/max for numbers, maxLength/pattern for text, date ranges, and select placeholders.

{
field: 'price',
type: 'number',
editable: true,
editorParams: {
min: 0, // Minimum value
max: 1000000, // Maximum value
step: 0.01, // Increment for up/down arrows
placeholder: 'Enter price'
}
}
{
field: 'name',
editable: true,
editorParams: {
maxLength: 100, // Maximum character length
pattern: '[A-Za-z\\s]+', // HTML5 validation pattern
placeholder: 'Enter name'
}
}
{
field: 'startDate',
type: 'date',
editable: true,
editorParams: {
min: '2024-01-01', // Minimum date (ISO format)
max: '2024-12-31', // Maximum date (ISO format)
placeholder: 'Select date',
default: '2024-01-01' // Fallback when non-nullable column is cleared
}
}
{
field: 'status',
type: 'select',
editable: true,
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' }
],
editorParams: {
includeEmpty: true, // Add empty option at start
emptyLabel: '-- Select --' // Label for empty option
}
}
import type {
EditorParams,
NumberEditorParams,
TextEditorParams,
DateEditorParams,
SelectEditorParams
} from '@toolbox-web/grid/plugins/editing';

Use the nullable column property to control whether a field can be set to null. Both true and false actively manage empty-input behaviour; omitting nullable preserves the default behaviour for each editor type.

Editors allow clearing the field and commit null:

columns: [
// Text & number: clearing the input commits null
{ field: 'nickname', editable: true, nullable: true },
{ field: 'bonus', type: 'number', editable: true, nullable: true },
// Select: a "(Blank)" option is prepended that commits null
{
field: 'department', type: 'select', editable: true, nullable: true,
options: [{ label: 'Engineering', value: 'eng' }, { label: 'Sales', value: 'sales' }],
},
// Date: clearing the date commits null
{ field: 'endDate', type: 'date', editable: true, nullable: true },
]

The select editor’s blank-option label defaults to "(Blank)" and can be customised via editorParams.emptyLabel.

Editors prevent null values by providing sensible defaults when the user clears a field:

EditorBehaviour when cleared
TextCommits "" (empty string)
NumberCommits editorParams.min if set, otherwise 0
DateCommits editorParams.default if set, otherwise today’s date
SelectNo blank option is shown — the user must pick a value
columns: [
{ field: 'name', editable: true, nullable: false },
{ field: 'price', type: 'number', editable: true, nullable: false,
editorParams: { min: 1 } }, // clears to 1
{ field: 'startDate', type: 'date', editable: true, nullable: false,
editorParams: { default: '2024-01-01' } }, // clears to Jan 1, 2024
]

Custom editors receive column.nullable via the ColumnEditorContext.column reference and can implement their own nullable logic.

Instead of configuring renderers and editors on every column, you can define type-level defaults that apply to all columns of a given type. This is especially useful for custom types like country, currency, or status that appear across multiple grids.

Define type defaults in your grid configuration:

import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');
// Define custom renderers/editors for types
grid.gridConfig = {
columns: [
{ field: 'name', header: 'Name', editable: true },
{ field: 'country', header: 'Country', type: 'country', editable: true },
{ field: 'priority', header: 'Priority', type: 'priority' },
],
// Type defaults apply to all columns with matching type
typeDefaults: {
country: {
renderer: (ctx) => {
const span = document.createElement('span');
span.textContent = `🌍 ${ctx.value}`;
return span;
},
editor: (ctx) => {
const select = document.createElement('select');
['USA', 'UK', 'Germany', 'France'].forEach(c => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
select.appendChild(opt);
});
select.value = ctx.value;
select.onchange = () => ctx.commit(select.value);
return select;
},
},
priority: {
renderer: (ctx) => {
const div = document.createElement('div');
div.className = `priority-${ctx.value}`;
div.textContent = ctx.value;
return div;
},
},
},
features: { editing: true },
};

For defaults that apply across all grids in your application, use the framework adapter’s type registry:

Vanilla JS does not have an app-level provider — use gridConfig.typeDefaults on each grid instance as shown in the Grid-Level Type Defaults section above.

When resolving a renderer or editor, the grid checks in this order:

  1. Column-levelcolumn.renderer or column.editor
  2. Grid-levelgridConfig.typeDefaults[column.type]
  3. App-level — Framework adapter’s type registry
  4. Built-in — Default editors for string, number, boolean, etc.

A column-level renderer/editor always takes precedence, allowing overrides when needed.

When using type defaults with editorParams, parameters are merged with column-level params taking precedence:

// Grid config
typeDefaults: {
number: {
editorParams: { min: 0, step: 1 }, // Type-level defaults
},
}
// Column config
{ field: 'price', type: 'number', editorParams: { step: 0.01 } }
// Result: { min: 0, step: 0.01 } — column's step overrides type default

The type property accepts any string, not just built-in types. Use descriptive names for your domain:

type: 'country' // Geographic data
type: 'currency' // Money values
type: 'priority' // High/Medium/Low
type: 'status' // Active/Pending/Archived
type: 'rating' // Star ratings

TypeScript provides IntelliSense for built-in types while allowing custom strings:

import type { ColumnType } from '@toolbox-web/grid';
const type: ColumnType = 'country'; // Works! Custom types allowed

For specialized input needs, provide a custom editor function:

{
field: 'status',
header: 'Status',
editable: true,
editor: (ctx) => {
const select = document.createElement('select');
select.innerHTML = `
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
`;
select.value = ctx.value;
// Commit on change
select.onchange = () => ctx.commit(select.value);
// Cancel on Escape
select.onkeydown = (e) => {
if (e.key === 'Escape') ctx.cancel();
};
return select;
},
}

Double-click the Priority column to see button-based editing:

The ctx object passed to custom editors contains:

PropertyTypeDescription
valueunknownCurrent cell value
rowTFull row data
columnColumnConfigColumn configuration
fieldstringField name
rowIdstringRow ID (from getRowId)
commit(value)functionCall to save new value
cancel()functionCall to discard changes
updateRow(changes)functionUpdate other fields on the same row (triggers cell-change events)
onValueChange(cb)functionRegister a callback to receive pushed values when the cell’s value changes externally (e.g., via updateRow from another cell’s commit)

When one cell’s commit updates other fields via updateRow(), any editors open on those fields receive the new value automatically. The grid does this for built-in editors out of the box.

For custom editors, use onValueChange to keep your inputs in sync:

{
field: 'total',
editable: true,
editor: (ctx) => {
const input = document.createElement('input');
input.type = 'number';
input.value = String(ctx.value);
// Stay in sync when another cell updates this field
ctx.onValueChange?.((newValue) => {
input.value = String(newValue ?? '');
});
input.onchange = () => ctx.commit(Number(input.value));
return input;
},
}

This is especially useful when fields are interdependent — for example, updating quantity recalculates total:

grid.on('cell-commit', ({ field, row, value, updateRow }) => {
if (field === 'quantity') {
const price = row.price;
updateRow({ total: price * value });
}
});
KeyAction
Enter (not editing)Start editing focused row
Enter (while editing)Commit edit and move down
TabCommit and move to next editable cell
Shift+TabCommit and move to previous editable cell
EscapeCancel edit, restore original value
Event Log:

The EditingPlugin emits events during the editing lifecycle. Double-click a cell to edit, then press Enter or click away to see the events:

EventTypeDescription
edit-openEditOpenDetailFired when a row enters edit mode (row mode only)
before-edit-closeBeforeEditCloseDetailFires synchronously before edit state is cleared on commit (row mode only). Managed editors (e.g. Angular Material overlay, MUI Popover) can flush pending values. Does not fire on revert.
edit-closeEditCloseDetailFired when a row exits edit mode — commit or cancel (row mode only)
cell-commitCellCommitDetailFired when a cell value is committed (cancelable)
row-commitRowCommitDetailFired when a row editing session ends (cancelable)
changed-rows-resetChangedRowsResetDetailFired when resetChangedRows() is called
dirty-changeDirtyChangeDetailFired when a row’s dirty state changes (requires dirtyTracking: true)
Validation Rules:
  • Email: Must contain @
  • Salary: Must be positive (> 0)

Try entering invalid values, then click outside the row.

Use setInvalid() in the cell-commit event to mark cells as invalid without canceling the edit. Invalid cells are highlighted with a red outline and can be styled with CSS custom properties.

Use preventDefault() in the row-commit event to reject the entire row if validation fails, reverting all changes to the original values.

The EditingPlugin provides these methods for managing validation state:

MethodDescription
setInvalid(rowId, field, message?)Mark a cell as invalid
clearInvalid(rowId, field)Clear invalid state for a cell
clearRowInvalid(rowId)Clear all invalid cells in a row
clearAllInvalid()Clear all invalid cells
isCellInvalid(rowId, field)Check if a cell is invalid
hasInvalidCells(rowId)Check if a row has any invalid cells
getInvalidMessage(rowId, field)Get the validation message

Style invalid cells by overriding these CSS variables:

tbw-grid {
--tbw-invalid-bg: #fef2f2;
--tbw-invalid-border-color: #ef4444;
}

If you use editable: true or editor without enabling the editing feature, the grid throws a helpful error:

[tbw-grid] Configuration error:
Column(s) [name, price] use the "editable" column property, but the required plugin is not loaded.
→ Enable the feature:
import '@toolbox-web/grid/features/editing';
features: { editing: true }

This runtime validation helps catch misconfigurations early during development.

Enable focusTrap to prevent accidental focus loss during editing. When focus leaves the grid during an active edit, it is automatically returned to the editing cell:

features: {
editing: {
editOn: 'dblclick',
focusTrap: true,
},
}

Elements registered via grid.registerExternalFocusContainer() are excluded from the trap — overlays (datepickers, dropdowns) continue to work normally.

Custom editors that append elements to <body> (e.g., datepicker overlays, dropdown panels) should register with the grid so focus inside them doesn’t close the editor:

// In your editor function — get the grid element from the DOM
editor: (ctx) => {
const input = document.createElement('input');
const overlay = document.createElement('div');
overlay.className = 'my-overlay';
document.body.appendChild(overlay);
// Get the grid element to register the overlay
const grid = input.closest('tbw-grid');
grid?.registerExternalFocusContainer(overlay);
// When done, unregister
const origCancel = ctx.cancel;
ctx.cancel = () => {
grid?.unregisterExternalFocusContainer(overlay);
overlay.remove();
origCancel();
};
return input;
}

Angular: BaseOverlayEditor handles registration automatically.

Enable dirty tracking to compare each row’s current data against its original (baseline) snapshot. This lets you detect which rows have been modified, display visual indicators, and revert changes — useful for “save changes” workflows.

features: {
editing: {
dirtyTracking: true,
},
}

When enabled, the plugin captures a deep-clone baseline of each row when data is first loaded (or when grid.rows is reassigned). Baselines are keyed by row ID, so gridConfig.getRowId (or a row id/_id property) is required.

Access via grid.getPluginByName('editing'):

Method / PropertyReturnsDescription
isDirty(rowId)booleanWhether the row differs from its baseline
isPristine(rowId)booleanOpposite of isDirty
dirtybooleanWhether any row is dirty
pristinebooleanWhether all rows are pristine
getDirtyRows()DirtyRowEntry[]All dirty rows with { id, original, current }
dirtyRowIdsstring[]IDs of all dirty rows
getOriginalRow(rowId)T | undefinedDeep clone of the baseline row
markAsPristine(rowId)voidRe-snapshot baseline from current data (call after save)
markAsDirty(rowId)voidForce-mark a row dirty (e.g., after external mutation)
markAllPristine()voidRe-snapshot all baselines (call after batch save)
revertRow(rowId)voidRevert a row to its baseline values

The dirty-change event fires whenever a row’s dirty state changes:

grid.on('dirty-change', ({ rowId, row, original, type }) => {
// type: 'modified' | 'new' | 'reverted' | 'pristine'
console.log(`Row ${rowId}: ${type}`);
});
const editing = grid.getPluginByName('editing');
// Check if there are unsaved changes
if (editing.dirty) {
const dirtyRows = editing.getDirtyRows();
await api.saveAll(dirtyRows.map(r => r.current));
editing.markAllPristine();
}

When dirty tracking is enabled, the EditingPlugin automatically applies CSS classes to rows based on their state:

CSS ClassApplied When
tbw-row-dirtyRow data differs from its baseline
tbw-row-newRow was inserted via insertRow()

These classes are toggled after every render, so they stay in sync with the data.

tbw-grid .data-grid-row.tbw-row-dirty {
background-color: #fffde7; /* Light yellow highlight */
}
tbw-grid .data-grid-row.tbw-row-new {
background-color: #e8f5e9; /* Light green highlight */
}

When rows are sorted, filtered, or grouped while a row is being edited, the EditingPlugin preserves the active edit session. The plugin tracks the editing row by identity (object reference) and remaps the edit index after the data pipeline runs, so the editor stays attached to the correct row even when its position changes.

If the editing row is removed from the processed data (e.g., filtered out), the edit session is automatically canceled to prevent stale state.

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