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.
Why Opt-In?
Section titled “Why Opt-In?”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: truewithout the plugin throws a helpful error
Installation
Section titled “Installation”import '@toolbox-web/grid/features/editing';Basic Usage
Section titled “Basic Usage”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 commitsgrid.on('cell-commit', (detail) => { console.log('Cell edited:', detail);});import '@toolbox-web/grid-react/features/editing';import { DataGrid, useGridEvent } from '@toolbox-web/grid-react';
const 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 },];
function MyGrid({ data }) { return ( <DataGrid rows={data} columns={columns} editing="dblclick" onCellCommit={(e) => console.log('Edited:', e.detail)} /> );}<script setup>import '@toolbox-web/grid-vue/features/editing';import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';
const data = [ { id: 1, name: 'Widget', price: 9.99, active: true }, { id: 2, name: 'Gadget', price: 19.99, active: false },];
const onCellCommit = (e) => { console.log('Cell edited:', e.detail);};</script>
<template> <TbwGrid :rows="data" editing="dblclick" @cell-commit="onCellCommit" style="height: 400px"> <TbwGridColumn field="id" header="ID" /> <TbwGridColumn field="name" header="Name" editable /> <TbwGridColumn field="price" header="Price" type="number" editable /> <TbwGridColumn field="active" header="Active" type="boolean" editable /> </TbwGrid></template>// Feature import - enables the [editing] inputimport '@toolbox-web/grid-angular/features/editing';
import { Component } from '@angular/core';import { Grid, TbwEditor } from '@toolbox-web/grid-angular';import type { ColumnConfig } from '@toolbox-web/grid';
@Component({ imports: [Grid, TbwEditor], template: ` <tbw-grid [rows]="data" [columns]="columns" [editing]="true" (cellCommit)="onCommit($event)" /> `})export class MyGridComponent { data = [...]; columns: ColumnConfig[] = [ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name', editable: true }, { field: 'price', header: 'Price', type: 'number', editable: true }, ];
onCommit(event: CustomEvent) { console.log('Edited:', event.detail); }}Try It
Section titled “Try It”<tbw-grid style="height: 300px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'score', header: 'Score', type: 'number', editable: true }, { field: 'role', header: 'Role', type: 'select', editable: true, options: [ { label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }, { label: 'Guest', value: 'guest' }, ], }, ], features: { editing: 'dblclick' }, };
grid.rows = [ { name: 'Alice', score: 95, role: 'admin' }, { name: 'Bob', score: 82, role: 'user' }, { name: 'Carol', score: 91, role: 'guest' }, ];
grid.on('cell-commit', ({ field, newValue }) => { console.log('Edited:', field, '→', newValue); });Double-click any cell to start editing. Press Enter to commit or Escape to cancel.
Add/Remove Rows
Section titled “Add/Remove Rows”<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
// Toolbar with Add Row button const toolbar = document.querySelector('[data-controls-id="editing-add-remove-rows-demo"]'); toolbar.style.cssText = 'padding: 8px; border-bottom: 1px solid #e5e7eb; display: flex; gap: 8px;';
const addBtn = document.createElement('button'); addBtn.textContent = '+ Add Row'; addBtn.style.cssText = ` padding: 6px 12px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; `; toolbar.appendChild(addBtn);
// Grid const grid = queryGrid('tbw-grid'); grid.style.cssText = 'flex: 1;';
let idCounter = 4;
grid.gridConfig = { columns: [ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name', editable: true }, { field: 'email', header: 'Email', editable: true }, { field: 'actions', header: 'Actions', renderer: (ctx) => { const btn = document.createElement('button'); btn.textContent = 'Delete'; btn.style.cssText = ` padding: 4px 8px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; `; btn.onclick = () => { const idx = grid.rows.findIndex((r) => r.id !== undefined && r.id === ctx.row.id); if (idx >= 0) grid.removeRow(idx); }; return btn; }, }, ], features: { editing: 'dblclick' }, };
grid.rows = [ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' }, { id: 3, name: 'Carol', email: 'carol@example.com' }, ];
addBtn.addEventListener('click', () => { grid.insertRow(grid.rows.length, { id: idCounter++, name: '', email: '' }); });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.
Programmatic Row Updates
Section titled “Programmatic Row Updates”To update individual row values without reassigning the entire rows array, use the Row Update API:
// Update a single row by IDgrid.updateRow('emp-123', { status: 'active', salary: 75000 });
// Batch update multiple rowsgrid.updateRows([ { id: 'emp-123', changes: { status: 'active' } }, { id: 'emp-456', changes: { department: 'Engineering' } },]);
// Listen for programmatic updatesgrid.on('cell-change', ({ rowId, changes }) => { console.log('Row updated:', rowId, changes);});See the API Reference for full documentation.
Edit Triggers
Section titled “Edit Triggers”Configure how editing is triggered with the editOn option:
features: { editing: 'dblclick' } // or 'click', 'manual'| Value | Behavior |
|---|---|
'click' | Single click on cell enters edit mode |
'dblclick' | Double-click on cell enters edit mode (default) |
'manual' | Programmatic only via beginCellEdit(rowIndex, field) |
<tbw-grid style="height: 250px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'email', header: 'Email', editable: true }, { field: 'role', header: 'Role', editable: true }, ], features: { editing: 'click' },};
grid.rows = [ { name: 'Alice', email: 'alice@example.com', role: 'Developer' }, { name: 'Bob', email: 'bob@example.com', role: 'Designer' }, { name: 'Carol', email: 'carol@example.com', role: 'Manager' }, { name: 'Dan', email: 'dan@example.com', role: 'Analyst' },];The demo above uses editOn: 'click' — a single click enters edit mode.
Keyboard Shortcuts (Row Mode)
Section titled “Keyboard Shortcuts (Row Mode)”| Key | Behavior |
|---|---|
| Enter | Start editing the entire row (all editable cells get editors) |
| F2 | Start editing only the focused cell (single-cell edit) |
| Escape | Cancel the current edit and revert changes |
| Tab / Shift+Tab | Move to next/previous editable cell |
| Arrow Up/Down | Commit current edit, exit edit mode, and move to adjacent row |
| Space | Toggle boolean cells (when not in edit mode) |
Grid Mode (Spreadsheet-like Editing)
Section titled “Grid Mode (Spreadsheet-like Editing)”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' } }| Mode | Behavior |
|---|---|
'row' | Default. Click/dblclick to enter edit mode, Escape to exit |
'grid' | All editors visible immediately. Excel-like navigation. |
In grid mode:
- All
editable: truecells render with their editors on load - Tab/Shift+Tab navigates between editable cells (wraps to next/prev row)
cell-commitevents fire normally when values change
Excel-like Navigation vs Edit Mode
Section titled “Excel-like Navigation vs Edit Mode”- Tab/Shift+Tab: Move between editable cells
- Enter: Commit current cell value
- Click outside or press Escape: No effect (always in edit mode)
import '@toolbox-web/grid';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: 'product', header: 'Product', editable: true }, { field: 'quantity', header: 'Qty', type: 'number', editable: true }, { field: 'price', header: 'Price', type: 'number', editable: true }, ], features: { editing: { mode: 'grid' } },};
grid.rows = [ { id: 1, product: 'Widget A', quantity: 10, price: 9.99 }, { id: 2, product: 'Widget B', quantity: 5, price: 14.99 }, { id: 3, product: 'Gadget X', quantity: 3, price: 29.99 }, { id: 4, product: 'Gadget Y', quantity: 8, price: 19.99 },];
grid.on('cell-commit', ({ field, value }) => { const log = document.querySelector('.event-log'); if (log) { const entry = document.createElement('div'); entry.textContent = `${field} = ${JSON.stringify(value)}`; log.insertBefore(entry, log.firstChild); while (log.children.length > 5) { log.removeChild(log.lastChild!); } }});<div class="instructions"> <strong>Grid Mode:</strong> All editable cells show editors immediately. <ul> <li><strong>Tab/Shift+Tab</strong>: Move between editable cells</li> <li><strong>Enter</strong>: Commit current cell value</li> <li>Click outside or press Escape: No effect (always in edit mode)</li> </ul> </div> <tbw-grid style="height: 250px;"></tbw-grid> <div class="log-section"> <strong>Recent Changes:</strong> <div class="event-log"></div> </div></div>
<style> #editing-grid-mode-demo .instructions { padding: 12px; background: var(--sl-color-gray-6); border: 1px solid var(--sl-color-gray-5); border-radius: 4px; font-size: 13px; margin-bottom: 8px; } #editing-grid-mode-demo .instructions ul { margin: 8px 0 0 16px; padding: 0; } #editing-grid-mode-demo .log-section { font-size: 12px; margin-top: 8px; } #editing-grid-mode-demo .event-log { font-family: monospace; font-size: 11px; max-height: 80px; overflow: auto; padding: 8px; background: var(--sl-color-gray-6); border-radius: 4px; margin-top: 4px; }</style>Grid mode supports Excel-style keyboard interaction with two modes:
| State | How to Enter | Behavior |
|---|---|---|
| Navigation | Press Escape | Arrow keys move between cells. Input is blurred. |
| Edit | Press Enter, click input, or start typing | Arrow 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.
Built-in Editors
Section titled “Built-in Editors”<tbw-grid style="height: 300px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns: [ { field: 'name', header: 'Name (string)', editable: true }, { field: 'age', header: 'Age (number)', type: 'number', editable: true }, { field: 'active', header: 'Active (boolean)', type: 'boolean', editable: true }, { field: 'joined', header: 'Joined (date)', type: 'date', editable: true }, { field: 'role', header: 'Role (select)', type: 'select', editable: true, options: [ { label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }, ], }, ], features: { editing: 'dblclick' }, };
grid.rows = [ { name: 'Alice', age: 28, active: true, joined: new Date('2023-01-15'), role: 'admin' }, { name: 'Bob', age: 34, active: false, joined: new Date('2023-06-20'), role: 'user' }, { name: 'Carol', age: 25, active: true, joined: new Date('2024-02-10'), role: 'user' }, ];The grid provides appropriate editors based on column type:
| Column Type | Editor |
|---|---|
string | Text input |
number | Number input with validation |
boolean | Checkbox |
date | Date picker input |
select | Dropdown (requires options array) |
Editor Parameters
Section titled “Editor Parameters”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.
<tbw-grid style="height: 250px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'price', header: 'Price', type: 'number', editable: true, editorParams: { min: 0, max: 1000, step: 0.01, placeholder: '0.00' }, }, { field: 'code', header: 'Product Code', editable: true, editorParams: { maxLength: 10, pattern: '[A-Z0-9]+', placeholder: 'ABC123' }, }, { field: 'expiry', header: 'Expiry Date', type: 'date', editable: true, editorParams: { min: '2024-01-01', max: '2030-12-31' }, }, { field: 'status', header: 'Status', type: 'select', editable: true, options: [ { label: 'Active', value: 'active' }, { label: 'Inactive', value: 'inactive' }, ], editorParams: { includeEmpty: true, emptyLabel: '-- Select --' }, }, ], features: { editing: 'dblclick' },};
grid.rows = [ { price: 29.99, code: 'PROD001', expiry: new Date('2025-06-15'), status: 'active' }, { price: 149.5, code: 'PROD002', expiry: new Date('2026-01-01'), status: 'inactive' }, { price: null, code: '', expiry: null, status: '' },];Number Editor
Section titled “Number Editor”{ 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' }}Text Editor
Section titled “Text Editor”{ field: 'name', editable: true, editorParams: { maxLength: 100, // Maximum character length pattern: '[A-Za-z\\s]+', // HTML5 validation pattern placeholder: 'Enter name' }}Date Editor
Section titled “Date Editor”{ 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 }}Select Editor
Section titled “Select Editor”{ 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 }}TypeScript Types
Section titled “TypeScript Types”import type { EditorParams, NumberEditorParams, TextEditorParams, DateEditorParams, SelectEditorParams} from '@toolbox-web/grid/plugins/editing';Nullable Columns
Section titled “Nullable Columns”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.
nullable: true
Section titled “nullable: true”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.
nullable: false
Section titled “nullable: false”Editors prevent null values by providing sensible defaults when the user clears a field:
| Editor | Behaviour when cleared |
|---|---|
| Text | Commits "" (empty string) |
| Number | Commits editorParams.min if set, otherwise 0 |
| Date | Commits editorParams.default if set, otherwise today’s date |
| Select | No 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.nullablevia theColumnEditorContext.columnreference and can implement their own nullable logic.
Type-Level Defaults
Section titled “Type-Level Defaults”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.
Grid-Level Type Defaults
Section titled “Grid-Level Type Defaults”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 typesgrid.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 },};import '@toolbox-web/grid-react/features/editing';import { DataGrid } from '@toolbox-web/grid-react';
// Type defaults with React componentsconst columns = [ { field: 'name', header: 'Name', editable: true }, { field: 'country', header: 'Country', type: 'country', editable: true }, { field: 'priority', header: 'Priority', type: 'priority' },];
const typeDefaults = { country: { renderer: (ctx) => <span>🌍 {ctx.value}</span>, editor: (ctx) => ( <select defaultValue={ctx.value} onChange={(e) => ctx.commit(e.target.value)} > <option value="USA">USA</option> <option value="UK">UK</option> <option value="Germany">Germany</option> </select> ), }, priority: { renderer: (ctx) => ( <span className={`priority-${ctx.value}`}>{ctx.value}</span> ), }, },};
function MyGrid({ data }) { return ( <DataGrid rows={data} columns={columns} typeDefaults={typeDefaults} editing={true} /> );}<script setup>import '@toolbox-web/grid-vue/features/editing';import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';import { h } from 'vue';
const data = [ { name: 'Alice', country: 'USA', priority: 'high' }, { name: 'Bob', country: 'Germany', priority: 'low' },];
// Grid-level type defaults are passed via gridConfigconst gridConfig = { typeDefaults: { country: { renderer: (ctx) => h('span', `🌍 ${ctx.value}`), 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) => h('span', { class: `priority-${ctx.value}` }, ctx.value), }, },};</script>
<template> <TbwGrid :rows="data" :gridConfig="gridConfig" editing="dblclick" style="height: 400px"> <TbwGridColumn field="name" header="Name" editable /> <TbwGridColumn field="country" header="Country" type="country" editable /> <TbwGridColumn field="priority" header="Priority" type="priority" /> </TbwGrid></template>// Feature import - enables the [editing] inputimport '@toolbox-web/grid-angular/features/editing';
import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { ColumnConfig, TypeDefault } from '@toolbox-web/grid';
@Component({ imports: [Grid], template: `<tbw-grid [rows]="data" [columns]="columns" [editing]="true" [typeDefaults]="typeDefaults" />`})export class MyGridComponent { data = [...]; columns: ColumnConfig[] = [ { field: 'name', header: 'Name', editable: true }, { field: 'country', header: 'Country', type: 'country', editable: true }, ];
typeDefaults: Record<string, TypeDefault> = { country: { renderer: (ctx) => { const span = document.createElement('span'); span.textContent = `🌍 ${ctx.value}`; return span; }, }, };}Application-Level Type Defaults
Section titled “Application-Level Type Defaults”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.
import { GridTypeProvider, type TypeDefaultsMap } from '@toolbox-web/grid-react';import { CountryBadge, CountryEditor } from './components';
// Define app-wide type defaultsconst typeDefaults: TypeDefaultsMap = { country: { renderer: (ctx) => <CountryBadge code={ctx.value} />, editor: (ctx) => ( <CountryEditor value={ctx.value} onCommit={ctx.commit} /> ), }, currency: { renderer: (ctx) => <span>${ctx.value.toFixed(2)}</span>, },};
function App() { return ( <GridTypeProvider defaults={typeDefaults}> <Dashboard /> </GridTypeProvider> );}
// Any grid with type: 'country' columns now uses these componentsfunction Dashboard() { return ( <DataGrid rows={employees} columns={[ { field: 'name', header: 'Name' }, { field: 'country', type: 'country', editable: true }, { field: 'salary', type: 'currency' }, ]} editing={true} /> );}<script setup>import { GridTypeProvider, type TypeDefaultsMap } from '@toolbox-web/grid-vue';import { h } from 'vue';import CountryBadge from './components/CountryBadge.vue';import CountryEditor from './components/CountryEditor.vue';
const typeDefaults: TypeDefaultsMap = { country: { renderer: (ctx) => h(CountryBadge, { code: ctx.value }), editor: (ctx) => h(CountryEditor, { modelValue: ctx.value, 'onUpdate:modelValue': ctx.commit, }), }, currency: { renderer: (ctx) => h('span', `$${ctx.value.toFixed(2)}`), },};</script>
<template> <GridTypeProvider :defaults="typeDefaults"> <Dashboard /> </GridTypeProvider></template><!-- Dashboard.vue — type defaults automatically apply --><script setup>import '@toolbox-web/grid-vue/features/editing';import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';
const employees = [...];</script>
<template> <TbwGrid :rows="employees" editing="dblclick" style="height: 400px"> <TbwGridColumn field="name" header="Name" /> <TbwGridColumn field="country" header="Country" type="country" editable /> <TbwGridColumn field="salary" header="Salary" type="currency" /> </TbwGrid></template>import { ApplicationConfig } from '@angular/core';import { provideGridTypeDefaults } from '@toolbox-web/grid-angular';import { CountryBadgeComponent, CountryEditorComponent } from './components';
export const appConfig: ApplicationConfig = { providers: [ provideGridTypeDefaults({ country: { renderer: CountryBadgeComponent, editor: CountryEditorComponent, }, currency: { renderer: CurrencyCellComponent, }, }), ],};
// grid.component.ts - type defaults automatically apply// Feature import - enables the [editing] inputimport '@toolbox-web/grid-angular/features/editing';
import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { ColumnConfig } from '@toolbox-web/grid';
@Component({ imports: [Grid], template: `<tbw-grid [rows]="data" [columns]="columns" [editing]="true" />`})export class GridComponent { data = [...]; columns: ColumnConfig[] = [ { field: 'name', header: 'Name' }, { field: 'country', type: 'country', editable: true }, // Uses registered components { field: 'salary', type: 'currency' }, ];}Resolution Priority
Section titled “Resolution Priority”When resolving a renderer or editor, the grid checks in this order:
- Column-level —
column.rendererorcolumn.editor - Grid-level —
gridConfig.typeDefaults[column.type] - App-level — Framework adapter’s type registry
- Built-in — Default editors for
string,number,boolean, etc.
A column-level renderer/editor always takes precedence, allowing overrides when needed.
Editor Parameters Merging
Section titled “Editor Parameters Merging”When using type defaults with editorParams, parameters are merged with column-level params taking precedence:
// Grid configtypeDefaults: { 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 defaultCustom Type Names
Section titled “Custom Type Names”The type property accepts any string, not just built-in types. Use descriptive names for your domain:
type: 'country' // Geographic datatype: 'currency' // Money valuestype: 'priority' // High/Medium/Lowtype: 'status' // Active/Pending/Archivedtype: 'rating' // Star ratingsTypeScript provides IntelliSense for built-in types while allowing custom strings:
import type { ColumnType } from '@toolbox-web/grid';
const type: ColumnType = 'country'; // Works! Custom types allowedCustom Editors
Section titled “Custom Editors”<tbw-grid style="height: 250px;"></tbw-grid>import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
const grid = await queryGrid('tbw-grid');
if (grid) { // Register custom styles for the editor grid.registerStyles( 'priority-editor', ` .data-grid-row > .cell.editing:has(.priority-editor) { justify-content: start; } .priority-editor { display: flex; gap: 4px; padding: 2px; } .priority-editor button { --button-background: light-dark(#ffffff, #333333); --button-color: light-dark(#333333, #ffffff); padding: 2px 8px; border: 1px solid #ccc; border-radius: 3px; background: var(--button-background); color: var(--button-color); cursor: pointer; user-select: none; } .priority-editor button.selected { --button-background: #3b82f6; --button-color: #ffffff; } `, );
grid.gridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'priority', header: 'Priority', editable: true, editor: (ctx) => { const editorEl = document.createElement('div'); editorEl.className = 'priority-editor';
['Low', 'Medium', 'High'].forEach((level) => { const btn = document.createElement('button'); btn.textContent = level; if (ctx.value === level) btn.classList.add('selected');
btn.onclick = () => { // Remove selected from siblings, add to clicked editorEl.querySelectorAll('button').forEach((b) => b.classList.remove('selected')); btn.classList.add('selected'); ctx.commit(level); }; editorEl.appendChild(btn); });
return editorEl; }, }, ], features: { editing: 'dblclick' }, };
grid.rows = [ { name: 'Task A', priority: 'High' }, { name: 'Task B', priority: 'Medium' }, { name: 'Task C', priority: 'Low' }, ];}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:
Editor Context
Section titled “Editor Context”The ctx object passed to custom editors contains:
| Property | Type | Description |
|---|---|---|
value | unknown | Current cell value |
row | T | Full row data |
column | ColumnConfig | Column configuration |
field | string | Field name |
rowId | string | Row ID (from getRowId) |
commit(value) | function | Call to save new value |
cancel() | function | Call to discard changes |
updateRow(changes) | function | Update other fields on the same row (triggers cell-change events) |
onValueChange(cb) | function | Register a callback to receive pushed values when the cell’s value changes externally (e.g., via updateRow from another cell’s commit) |
Cascade Updates (onValueChange)
Section titled “Cascade Updates (onValueChange)”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; },}import { useState, useEffect } from 'react';
// Custom editor component that stays in sync with cascade updatesfunction TotalEditor({ value, onValueChange, commit }) { const [total, setTotal] = useState(value ?? 0);
// Stay in sync when another cell updates this field useEffect(() => { onValueChange?.((newValue) => setTotal(newValue ?? 0)); }, [onValueChange]);
return ( <input type="number" value={total} onChange={(e) => { const num = Number(e.target.value); setTotal(num); commit(num); }} /> );}
// Use in column configconst columns = [ { field: 'total', editable: true, editor: (ctx) => ( <TotalEditor value={ctx.value} onValueChange={ctx.onValueChange} commit={ctx.commit} /> ), },];<!-- In TbwGridColumn #editor slot --><TbwGridColumn field="total" header="Total" editable> <template #editor="{ value, onValueChange, commit }"> <TotalEditor :value="value" :on-value-change="onValueChange" @commit="commit" /> </template></TbwGridColumn><script setup>import { ref, onMounted } from 'vue';
const props = defineProps(['value', 'onValueChange']);const emit = defineEmits(['commit']);const total = ref(props.value ?? 0);
onMounted(() => { props.onValueChange?.((newValue) => { total.value = newValue ?? 0; });});</script>
<template> <input type="number" :value="total" @change="(e) => { total = Number(e.target.value); emit('commit', total); }" /></template>// Angular editors receive onValueChange in the editor context// BaseGridEditor handles it automatically for ControlValueAccessor editors.// For manual editors, subscribe in your component:
import { Component, OnInit } from '@angular/core';import { BaseGridEditor } from '@toolbox-web/grid-angular';
@Component({ template: `<input type="number" [value]="total" (change)="onInput($event)" />`})export class TotalEditorComponent extends BaseGridEditor<number> implements OnInit { total = 0;
ngOnInit() { this.total = this.context.value ?? 0;
// Stay in sync when another cell updates this field this.context.onValueChange?.((newValue) => { this.total = newValue ?? 0; }); }
onInput(event: Event) { const num = Number((event.target as HTMLInputElement).value); this.total = num; this.context.commit(num); }}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 }); }});<DataGrid rows={data} columns={columns} editing="dblclick" onCellCommit={(e) => { if (e.detail.field === 'quantity') { const price = e.detail.row.price; e.detail.updateRow({ total: price * e.detail.value }); } }}/><TbwGrid :rows="data" editing="dblclick" @cell-commit="(e) => { if (e.detail.field === 'quantity') { const price = e.detail.row.price; e.detail.updateRow({ total: price * e.detail.value }); } }"> <!-- columns --></TbwGrid>@Component({ template: `<tbw-grid [rows]="data" [columns]="columns" [editing]="true" (cellCommit)="onCommit($event)" />`})export class MyGridComponent { onCommit(event: CustomEvent) { if (event.detail.field === 'quantity') { const price = event.detail.row.price; event.detail.updateRow({ total: price * event.detail.value }); } }}Keyboard Shortcuts
Section titled “Keyboard Shortcuts”| Key | Action |
|---|---|
| Enter (not editing) | Start editing focused row |
| Enter (while editing) | Commit edit and move down |
| Tab | Commit and move to next editable cell |
| Shift+Tab | Commit and move to previous editable cell |
| Escape | Cancel edit, restore original value |
Events
Section titled “Events”import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'department', header: 'Department', editable: true }, { field: 'salary', header: 'Salary', type: 'number', editable: true }, ], features: { editing: 'dblclick' },};
grid.rows = [ { id: 1, name: 'Alice Johnson', department: 'Engineering', salary: 85000 }, { id: 2, name: 'Bob Smith', department: 'Marketing', salary: 72000 }, { id: 3, name: 'Carol White', department: 'Sales', salary: 68000 },];
const log = document.querySelector('#editing-event-log');const clearBtn = document.querySelector('#clear-editing-log');
function addLog(type: string, detail: string) { if (!log) return; const msg = document.createElement('div'); msg.innerHTML = `<span class="event-type">[${type}]</span> ${detail}`; log.insertBefore(msg, log.firstChild); while (log.children.length > 15) { log.lastChild?.remove(); }}
clearBtn?.addEventListener('click', () => { if (log) log.innerHTML = '';});
grid.on('edit-open', (d) => { addLog('edit-open', `row ${d.rowIndex} (${d.rowId})`);});
grid.on('edit-close', (d) => { addLog('edit-close', `row ${d.rowIndex} (${d.rowId}), reverted: ${d.reverted}`);});
grid.on('cell-commit', (d) => { addLog('cell-commit', `field="${d.field}", "${d.oldValue}" → "${d.value}"`);});
grid.on('row-commit', (d) => { addLog('row-commit', `row ${d.rowIndex} (${d.rowId}), changed: ${d.changed}`);});
grid.on('changed-rows-reset', (d) => { addLog('changed-rows-reset', `${d.rows?.length || 0} rows cleared`);});<tbw-grid style="height: 300px;"></tbw-grid> <div class="log-panel"> <div class="log-header"> <strong>Event Log:</strong> <button>Clear</button> </div> <div class="event-log"></div> </div></div>
<style> #editing-editing-events-demo .log-panel { border: 1px solid var(--sl-color-gray-5); padding: 12px; border-radius: 4px; background: var(--sl-color-gray-6); overflow-y: auto; max-height: 300px; margin-top: 8px; } #editing-editing-events-demo .log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } #editing-editing-events-demo .log-header button { padding: 4px 8px; cursor: pointer; font-size: 12px; } #editing-editing-events-demo .event-log { font-family: monospace; font-size: 11px; color: var(--sl-color-gray-2); } #editing-editing-events-demo .event-log > div { padding: 2px 0; border-bottom: 1px solid var(--sl-color-gray-5); } #editing-editing-events-demo .event-log .event-type { color: var(--sl-color-accent); }</style>The EditingPlugin emits events during the editing lifecycle. Double-click a cell to edit, then press Enter or click away to see the events:
| Event | Type | Description |
|---|---|---|
edit-open | EditOpenDetail | Fired when a row enters edit mode (row mode only) |
before-edit-close | BeforeEditCloseDetail | Fires 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-close | EditCloseDetail | Fired when a row exits edit mode — commit or cancel (row mode only) |
cell-commit | CellCommitDetail | Fired when a cell value is committed (cancelable) |
row-commit | RowCommitDetail | Fired when a row editing session ends (cancelable) |
changed-rows-reset | ChangedRowsResetDetail | Fired when resetChangedRows() is called |
dirty-change | DirtyChangeDetail | Fired when a row’s dirty state changes (requires dirtyTracking: true) |
Cell Validation
Section titled “Cell Validation”- Email: Must contain @
- Salary: Must be positive (> 0)
Try entering invalid values, then click outside the row.
import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'email', header: 'Email', editable: true }, { field: 'salary', header: 'Salary', type: 'number', editable: true }, ], features: { editing: 'dblclick' }, };
grid.rows = [ { id: 1, name: 'Alice', email: 'alice@example.com', salary: 85000 }, { id: 2, name: 'Bob', email: 'bob@example.com', salary: 72000 }, { id: 3, name: 'Carol', email: 'carol@example.com', salary: 68000 }, ];
// Mark invalid cells (but don't cancel the edit) grid.on('cell-commit', ({ field, value, setInvalid }) => {
if (field === 'email' && typeof value === 'string' && !value.includes('@')) { setInvalid('Email must contain @'); }
if (field === 'salary' && typeof value === 'number' && value <= 0) { setInvalid('Salary must be positive'); } });
// Reject row commit if there are invalid cells grid.on('row-commit', ({ rowId }, e) => { if (editingPlugin.hasInvalidCells(rowId)) { e.preventDefault(); // Reverts entire row to original values // In real app, show a toast or inline message instead of alert console.warn('Row reverted due to validation errors'); } });<div style="padding: 12px; background: var(--sl-color-gray-6); border: 1px solid var(--sl-color-gray-5); border-radius: 4px; font-size: 13px; margin-bottom: 8px;"> <strong>Validation Rules:</strong> <ul style="margin: 8px 0 0 16px; padding: 0;"> <li><strong>Email</strong>: Must contain @</li> <li><strong>Salary</strong>: Must be positive (> 0)</li> </ul> <p style="margin: 8px 0 0;">Try entering invalid values, then click outside the row.</p></div><tbw-grid style="height: 250px;"></tbw-grid>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.
Validation Methods
Section titled “Validation Methods”The EditingPlugin provides these methods for managing validation state:
| Method | Description |
|---|---|
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 |
CSS Custom Properties
Section titled “CSS Custom Properties”Style invalid cells by overriding these CSS variables:
tbw-grid { --tbw-invalid-bg: #fef2f2; --tbw-invalid-border-color: #ef4444;}Configuration Validation
Section titled “Configuration Validation”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.
Focus Management
Section titled “Focus Management”Focus Trap
Section titled “Focus Trap”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.
External Focus Containers
Section titled “External Focus Containers”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 DOMeditor: (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:
BaseOverlayEditorhandles registration automatically.
Dirty Tracking
Section titled “Dirty Tracking”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.
Configuration
Section titled “Configuration”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.
API Methods
Section titled “API Methods”Access via grid.getPluginByName('editing'):
| Method / Property | Returns | Description |
|---|---|---|
isDirty(rowId) | boolean | Whether the row differs from its baseline |
isPristine(rowId) | boolean | Opposite of isDirty |
dirty | boolean | Whether any row is dirty |
pristine | boolean | Whether all rows are pristine |
getDirtyRows() | DirtyRowEntry[] | All dirty rows with { id, original, current } |
dirtyRowIds | string[] | IDs of all dirty rows |
getOriginalRow(rowId) | T | undefined | Deep clone of the baseline row |
markAsPristine(rowId) | void | Re-snapshot baseline from current data (call after save) |
markAsDirty(rowId) | void | Force-mark a row dirty (e.g., after external mutation) |
markAllPristine() | void | Re-snapshot all baselines (call after batch save) |
revertRow(rowId) | void | Revert a row to its baseline values |
Events
Section titled “Events”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}`);});Example: Save Workflow
Section titled “Example: Save Workflow”const editing = grid.getPluginByName('editing');
// Check if there are unsaved changesif (editing.dirty) { const dirtyRows = editing.getDirtyRows(); await api.saveAll(dirtyRows.map(r => r.current)); editing.markAllPristine();}Row CSS Classes
Section titled “Row CSS Classes”When dirty tracking is enabled, the EditingPlugin automatically applies CSS classes to rows based on their state:
| CSS Class | Applied When |
|---|---|
tbw-row-dirty | Row data differs from its baseline |
tbw-row-new | Row was inserted via insertRow() |
These classes are toggled after every render, so they stay in sync with the data.
Styling Dirty Rows
Section titled “Styling Dirty Rows”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 */}Editing Stability
Section titled “Editing Stability”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.
See Also
Section titled “See Also”- Undo/Redo — Track edit history with Ctrl+Z/Y
- Clipboard — Copy/paste with Ctrl+C/V
- Selection — Cell and row selection
- Common Patterns — Full application recipes with editing
- Plugins Overview — Plugin compatibility and combinations