# Editing Plugin

> Enable inline cell editing with built-in and custom editors.

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?

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

## Installation

```ts
import '@toolbox-web/grid/features/editing';
```

## Basic Usage

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

#### TypeScript

```ts
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);
});
```

#### React

```tsx
import '@toolbox-web/grid-react/features/editing';
import { DataGrid } 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)}
    />
  );
}
```

#### Vue

```html
<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>
```

#### Angular

```typescript
// Feature import - enables the [editing] input
import { GridEditingDirective, TbwEditor } from '@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, TbwEditor, GridEditingDirective],
  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

```ts
// EditingBasicEditingDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-basic-editing-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

      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.

## Built-in Editors

The editor used for a cell is chosen from the column's `type`. No editor code is required for the common cases — set `type` and `editable: true`, and the matching native input is rendered:

| Column `type`        | Built-in editor   | Configure via                                                          |
| -------------------- | ----------------- | ---------------------------------------------------------------------- |
| `'string'` (default) | Text `<input>`    | `editorParams`: `maxLength`, `pattern`, `placeholder`                  |
| `'number'`           | Number `<input>`  | `editorParams`: `min`, `max`, `step`, `placeholder`                    |
| `'date'`             | Date `<input>`    | `editorParams`: `min`, `max` (ISO `'YYYY-MM-DD'`), `placeholder`, `default` |
| `'boolean'`          | Checkbox          | _(no params)_                                                          |
| `'select'`           | Dropdown `<select>` | column `options`; `editorParams`: `includeEmpty`, `emptyLabel`       |

`editorParams` is set on the column and tunes the built-in editor's HTML attributes:

```ts
grid.gridConfig = {
  columns: [
    { field: 'name', editable: true, editorParams: { maxLength: 50, placeholder: 'Full name' } },
    { field: 'price', type: 'number', editable: true, editorParams: { min: 0, step: 0.01 } },
    { field: 'hireDate', type: 'date', editable: true, editorParams: { min: '2020-01-01' } },
    {
      field: 'department',
      type: 'select',
      editable: true,
      // `options` is a column property — an array of { label, value } (or a function returning one).
      options: [
        { label: 'Engineering', value: 'eng' },
        { label: 'Sales', value: 'sales' },
      ],
      editorParams: { includeEmpty: true, emptyLabel: '-- Select --' },
    },
  ],
  features: { editing: 'dblclick' },
};
```

For anything beyond these — overlay pickers, multi-field composites, or framework components — provide a custom `editor` on the column (see [Custom Editors](#custom-editors) below) or, in a framework adapter, the adapter's editor base class. The select editor's choices come from the column-level `options` property (not `editorParams`); `editorParams.includeEmpty`/`emptyLabel` only control the leading blank entry.

## Add/Remove Rows

```ts
// EditingAddRemoveRowsDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-add-remove-rows-demo');
if (container) {
  // Toolbar with Add Row button
      const toolbar = container.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', container);
      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

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

```typescript
// 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);
});
```

:::note
The `cell-change` event fires for programmatic updates via `updateRow()`/`updateRows()`. The `cell-commit` event fires for inline edits made by users.
:::

See the [API Reference](/grid/api-reference.md#row-update-api) for full documentation.

## Conditional Editing

By default, `editable: true` makes a column editable for **every** row. For more fine-grained control, use a **function** to decide per-row whether a cell can be edited.

### Try It

```ts
// EditingConditionalDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-conditional-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

  grid.gridConfig = {
    // Row-level gate: archived rows cannot be edited at all
    rowEditable: (row) => !row.archived,
    columns: [
      { field: 'name', header: 'Name', editable: true },
      { field: 'price', header: 'Price', type: 'number', editable: (row) => row.status === 'draft' },
      {
        field: 'status',
        header: 'Status',
        type: 'select',
        editable: true,
        options: [
          { label: 'Draft', value: 'draft' },
          { label: 'Published', value: 'published' },
        ],
      },
      {
        field: 'archived',
        header: 'Archived',
        type: 'boolean',
        renderer: (ctx) => {
          const span = document.createElement('span');
          span.textContent = ctx.value ? '🔒 Yes' : 'No';
          return span;
        },
      },
    ],
    features: { editing: 'dblclick' },
  };

  grid.rows = [
    { name: 'Widget', price: 9.99, status: 'draft', archived: false },
    { name: 'Gadget', price: 19.99, status: 'published', archived: false },
    { name: 'Doohickey', price: 4.99, status: 'draft', archived: false },
    { name: 'Thingamajig', price: 29.99, status: 'published', archived: true },
  ];
}
```

In this demo, **Price** is only editable on "draft" rows, and the archived row (Thingamajig) is locked entirely via `rowEditable`.

#### TypeScript

```ts
const columns = [
  { field: 'name', editable: true }, // always editable
  { field: 'price', editable: (row) => row.status === 'draft' }, // only draft rows
];
```

#### React

```tsx
const columns = [
  { field: 'name', editable: true },
  { field: 'price', editable: (row) => row.status === 'draft' },
];

<DataGrid rows={data} columns={columns} editing="dblclick" />
```

#### Vue

```html
<script setup>
const columns = [
  { field: 'name', editable: true },
  { field: 'price', editable: (row) => row.status === 'draft' },
];
</script>

<template>
  <TbwGrid :rows="data" :columns="columns" editing="dblclick" />
</template>
```

#### Angular

```typescript
columns: ColumnConfig[] = [
  { field: 'name', editable: true },
  { field: 'price', editable: (row: any) => row.status === 'draft' },
];
```

### Row-Level Editability (`rowEditable`)

To block editing for an **entire row** regardless of column config, set `rowEditable` on `gridConfig`. This acts as a gate before any column-level `editable` check:

```ts
grid.gridConfig = {
  rowEditable: (row) => !row.archived,
  columns: [
    { field: 'name', editable: true },
    { field: 'price', editable: (row) => row.status === 'draft' },
  ],
  features: { editing: 'dblclick' },
};
```

**Resolution order** — a cell is editable when **both** pass:
1. `gridConfig.rowEditable(row)` returns `true` (or is omitted)
2. Column `editable` is `true` or `editable(row)` returns `true`

:::tip
Keep `editable` and `rowEditable` callbacks fast — they are called on every editability check (click, keyboard, grid-mode render, tab navigation).
:::

## Edit Triggers

Configure how editing is triggered with the `editOn` option:

```ts
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)` |

```ts
// EditingClickToEditDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-click-to-edit-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

  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)

| 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** | Handled by the focused editor (e.g. number stepper, select options, textarea caret). Press **Enter**, **Escape**, or **Tab** to leave edit mode before arrow keys resume cell navigation. |
| **Space** | Toggle boolean cells (when not in edit mode) |

:::tip
Use **F2** when you only need to edit one cell without activating editors on the entire row.
:::

## 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:

```ts
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: 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

### Excel-like Navigation vs Edit Mode

```ts
// EditingGridModeDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-grid-mode-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

  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 = container.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!);
      }
    }
  });
}
```

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

```ts
// EditingAllColumnTypesDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-all-column-types-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

      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

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

```ts
// EditorParametersDemo.astro
  import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/editing';

  const grid = queryGrid('#demo-editor-parameters');
  if (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

```ts
{
  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

```ts
{
  field: 'name',
  editable: true,
  editorParams: {
    maxLength: 100,          // Maximum character length
    pattern: '[A-Za-z\\s]+', // HTML5 validation pattern
    placeholder: 'Enter name'
  }
}
```

### Date Editor

```ts
{
  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

```ts
{
  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

```ts
import type {
  EditorParams,
  NumberEditorParams,
  TextEditorParams,
  DateEditorParams,
  SelectEditorParams
} from '@toolbox-web/grid/plugins/editing';
```

## 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

Editors allow clearing the field and commit `null`:

```ts
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

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                   |

```ts
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.

## 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

Define type defaults in your grid configuration:

#### TypeScript

```ts
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 },
};
```

#### React

```tsx
import '@toolbox-web/grid-react/features/editing';
import { DataGrid } from '@toolbox-web/grid-react';

// Type defaults with React components
const 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}
    />
  );
}
```

#### Vue

```html
<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 gridConfig
const 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>
```

#### Angular

```typescript
// Feature import - enables the [editing] input
import { GridEditingDirective } from '@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, GridEditingDirective],
  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

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

#### TypeScript

Vanilla JS does not have an app-level provider — use `gridConfig.typeDefaults` on each grid instance
as shown in the [Grid-Level Type Defaults](#grid-level-type-defaults) section above.

#### React

```tsx
// app.tsx
import { GridTypeProvider, type TypeDefaultsMap } from '@toolbox-web/grid-react';
import { CountryBadge, CountryEditor } from './components';

// Define app-wide type defaults
const 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 components
function Dashboard() {
  return (
    <DataGrid
      rows={employees}
      columns={[
        { field: 'name', header: 'Name' },
        { field: 'country', type: 'country', editable: true },
        { field: 'salary', type: 'currency' },
      ]}
      editing={true}
    />
  );
}
```

#### Vue

```html
<!-- App.vue -->
<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>
```

```html
<!-- 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>
```

#### Angular

```typescript
// app.config.ts
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] input
import { GridEditingDirective } from '@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, GridEditingDirective],
  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

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

1. **Column-level** — `column.renderer` or `column.editor`
2. **Grid-level** — `gridConfig.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.

### Editor Parameters Merging

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

```ts
// 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
```

### Custom Type Names

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

```ts
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:

```ts
import type { ColumnType } from '@toolbox-web/grid';

const type: ColumnType = 'country'; // Works! Custom types allowed
```

## Custom Editors

```ts
// EditingCustomEditorDemo.astro
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-custom-editor-demo');
if (container) {
  const grid = await queryGrid('tbw-grid', container, true);

  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:

```ts
{
  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

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) |

:::tip[Preventing Row Height Growth]
Built-in editors are sized to match the cell exactly, so row height stays constant when entering edit mode. Custom editors inherit this behavior automatically if they return a standard `<input>`, `<select>`, or `<textarea>` element.

If your custom editor uses a non-standard element (e.g., a `<div>` with buttons), ensure it doesn't exceed the cell height. Add these styles to your editor's root element:

```css
max-height: 100%;
overflow: hidden;
```

For editors that need overlays (datepickers, dropdown panels), render the overlay **outside** the cell using `position: fixed` or `position: absolute` on a `<body>`-appended element, and register it with `grid.registerExternalFocusContainer(overlay)` so the grid doesn't close the editor when the overlay receives focus.
:::

### 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:

#### TypeScript

```ts
{
  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;
  },
}
```

#### React

```tsx
import { useState, useEffect } from 'react';

// Custom editor component that stays in sync with cascade updates
function 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 config
const columns = [
  {
    field: 'total',
    editable: true,
    editor: (ctx) => (
      <TotalEditor
        value={ctx.value}
        onValueChange={ctx.onValueChange}
        commit={ctx.commit}
      />
    ),
  },
];
```

#### Vue

```html
<!-- 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>
```

```html
<!-- TotalEditor.vue -->
<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

```typescript
// 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/features/editing';

@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`:

#### TypeScript

```ts
grid.on('cell-commit', ({ field, row, value, updateRow }) => {
  if (field === 'quantity') {
    const price = row.price;
    updateRow({ total: price * value });
  }
});
```

#### React

```tsx
<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 });
    }
  }}
/>
```

#### Vue

```html
<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>
```

#### Angular

```typescript
@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

| 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

```ts
// EditingEditingEventsDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-editing-events-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

  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 = container.querySelector('#editing-event-log');
  const clearBtn = container.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`);
  });
}
```

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`](/grid/plugins/editing/interfaces/beforeeditclosedetail.md) | 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`](/grid/plugins/editing/interfaces/editclosedetail.md) | Fired when a row exits edit mode — commit or cancel (row mode only) |
| `cell-commit` | [`CellCommitDetail`](/grid/plugins/editing/interfaces/cellcommitdetail.md) | Fired when a cell value is committed (cancelable) |
| `row-commit` | [`RowCommitDetail`](/grid/plugins/editing/interfaces/rowcommitdetail.md) | Fired when a row editing session ends (cancelable) |
| `changed-rows-reset` | [`ChangedRowsResetDetail`](/grid/plugins/editing/interfaces/changedrowsresetdetail.md) | Fired when `resetChangedRows()` is called |
| `dirty-change` | [`DirtyChangeDetail`](/grid/plugins/editing/interfaces/dirtychangedetail.md) | Fired when a row's dirty state changes (requires `dirtyTracking: true`) |

## Cell Validation

```ts
// EditingCellValidationDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/editing';

const container = document.getElementById('editing-cell-validation-demo');
if (container) {
  const grid = queryGrid('tbw-grid', container);

      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');
        }
      });
}
```

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

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

Style invalid cells by overriding these CSS variables:

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

## 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.

### Opting out without removing column config

If you want to keep `editor` / `editable` declarations on your columns but
temporarily turn editing off (e.g. for a read-only mode), set
`features.editing` to `false` explicitly. The validator treats this as an
intentional opt-out and will not throw, so you can flip the feature back on
without touching column definitions:

```ts
gridConfig = {
  features: { editing: false }, // explicit off — no validation error
  columns: [{ field: 'name', editor: 'text' }],
};
```

## Focus Management

### 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:

```ts
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

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:

```ts
// 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.

## 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

```ts
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

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

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

```ts
grid.on('dirty-change', ({ rowId, row, original, type }) => {
  // type: 'modified' | 'new' | 'reverted' | 'pristine'
  console.log(`Row ${rowId}: ${type}`);
});
```

### Example: Save Workflow

```ts
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();
}
```

## Imperative Bulk-Edit API

For programmatic row editing (e.g. wiring an "Edit" toolbar button, building a custom save flow, or driving edits from outside the grid), the Editing plugin installs the following methods and getters directly on the grid instance:

```typescript
// Start row editing programmatically (all editable cells in the row enter edit mode)
grid.beginBulkEdit(rowIndex);

// Commit the active row edit (fires `row-commit`, applies pending values)
grid.commitActiveRowEdit();

// Cancel the active row edit (discards pending values)
grid.cancelActiveRowEdit();

// Snapshot of rows whose data has been changed this session
const changes: T[] = grid.changedRows;

// IDs of changed rows — requires `gridConfig.getRowId` (or an `id`/`_id` field)
const ids: string[] = grid.changedRowIds;

// Clear change tracking — pass `silent: true` to suppress events
grid.resetChangedRows();
```

:::tip[changedRows vs Dirty Tracking]
`changedRows` / `changedRowIds` is the lightweight session-level "what did the user commit?" tracker — it grows as each cell-/row-commit lands and is cleared by `resetChangedRows()`.

For richer per-row baseline diffing (original vs current, revert, programmatic mark-dirty), enable [Dirty Tracking](#dirty-tracking) and use the plugin's `getDirtyRows()` / `revertRow()` / `markAsPristine()` API instead.
:::

## 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

```css
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

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

- **[Undo/Redo](/grid/plugins/undo-redo.md)** — Track edit history with Ctrl+Z/Y
- **[Clipboard](/grid/plugins/clipboard.md)** — Copy/paste with Ctrl+C/V
- **[Selection](/grid/plugins/selection.md)** — Cell and row selection
- **[Common Patterns](/grid/guides/common-patterns.md)** — Full application recipes with editing
- **[Plugins Overview](/grid/plugins.md)** — Plugin compatibility and combinations
