# Core Features

> Interactive playground, configuration, rendering, loading states, variable row heights, events, methods, and more for @toolbox-web/grid.

This page documents built-in grid features that don't require plugins — the interactive playground, column configuration, data formatting, styling, loading states, events, methods, and more.

---

## Basic Usage

### Interactive Playground

Experiment with the grid's core options in real time. Adjust the row count, toggle columns, change the fit mode, and enable or disable sortable/resizable columns.

```ts
// InteractivePlaygroundDemo.astro
  import '@toolbox-web/grid';
import type { ColumnConfig, FitMode } from '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/editing';

  type ColumnKey = 'id' | 'name' | 'active' | 'score' | 'created' | 'role';

  const container = document.getElementById('interactive-playground-demo');
  const grid = queryGrid('tbw-grid', container!);

  if (container && grid) {
    const allColumnDefs: Record<ColumnKey, ColumnConfig> = {
      id: { field: 'id', header: 'ID', type: 'number', sortable: true, resizable: true },
      name: { field: 'name', header: 'Name', sortable: true, resizable: true },
      active: { field: 'active', header: 'Active', type: 'boolean', sortable: true },
      score: { field: 'score', header: 'Score', type: 'number', sortable: true, resizable: true },
      created: {
        field: 'created', header: 'Created', type: 'date', sortable: true, resizable: true,
      },
      role: {
        field: 'role', header: 'Role', type: 'select', sortable: true,
        options: [
          { label: 'Admin', value: 'admin' },
          { label: 'User', value: 'user' },
          { label: 'Guest', value: 'guest' },
        ],
      },
    };

    function generateRows(count: number) {
      const roles = ['admin', 'user', 'guest'];
      const names = ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Henry'];
      const rows: Record<string, unknown>[] = [];
      for (let i = 0; i < count; i++) {
        rows.push({
          id: i + 1,
          name: names[i % names.length] + ' ' + (Math.floor(i / names.length) + 1),
          active: i % 3 !== 0,
          score: Math.floor(Math.random() * 100),
          created: new Date(Date.now() - i * 86400000),
          role: roles[i % roles.length],
        });
      }
      return rows;
    }

    let state = {
      rowCount: 100,
      columns: ['id', 'name', 'active', 'score', 'created', 'role'] as ColumnKey[],
      fitMode: 'stretch' as FitMode,
      sortable: true,
      resizable: true,
    };

    function rebuild() {
      const columns = state.columns.map((key) => ({
        ...allColumnDefs[key],
        sortable: state.sortable,
        resizable: state.resizable,
      }));

      grid.fitMode = state.fitMode;
      grid.gridConfig = {
        columns,
        sortable: state.sortable,
        resizable: state.resizable,
        typeDefaults: {
          date: {
            format: (val: Date) =>
              val.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' }),
          },
        },
        features: { editing: 'dblclick' },
      };
      grid.rows = generateRows(state.rowCount);
    }

    // Initial render
    rebuild();

    // Re-render on control change
    container.addEventListener('control-change', ((e: CustomEvent) => {
      const { allValues } = e.detail;
      state = {
        rowCount: allValues.rowCount as number,
        columns: (allValues.columns as ColumnKey[]) ?? state.columns,
        fitMode: (allValues.fitMode as FitMode) ?? state.fitMode,
        sortable: allValues.sortable as boolean,
        resizable: allValues.resizable as boolean,
      };
      rebuild();
    }) as EventListener);
  }
```

### Keyboard Navigation

The grid implements [ARIA grid keyboard patterns](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) out of the box — no configuration required:

| Key | Action |
|-----|--------|
| <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> | Move between cells |
| <kbd>Home</kbd> / <kbd>End</kbd> | Jump to first/last cell in row |
| <kbd>Ctrl</kbd> + <kbd>Home</kbd> / <kbd>Ctrl</kbd> + <kbd>End</kbd> | Jump to first/last cell in grid |
| <kbd>PgUp</kbd> / <kbd>PgDn</kbd> | Scroll by viewport height |
| <kbd>↵ Enter</kbd> | Start editing (with EditingPlugin) |
| <kbd>Esc</kbd> | Cancel editing |
| <kbd>⇥ Tab</kbd> / <kbd>⇧ Shift</kbd> + <kbd>⇥ Tab</kbd> | Move to next/previous editable cell |

For the full keyboard shortcut reference, see the [Accessibility guide](/grid/guides/accessibility.md).

### RTL (Right-to-Left) Support

The grid fully supports RTL languages like Hebrew, Arabic, and Persian. Set `dir="rtl"` on the grid or any ancestor element — keyboard navigation, column pinning, and layout all adapt automatically.

```html
<tbw-grid dir="rtl"></tbw-grid>
```

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

  const container = document.getElementById('rtl-demo-container');
  const grid = queryGrid('#demo-rtl');
  if (container && grid) {
    grid.columns = [
      { field: 'name', header: 'الاسم', width: 150 },
      { field: 'department', header: 'القسم', width: 130 },
      { field: 'salary', header: 'الراتب', width: 120, formatter: (v: number) => `${v.toLocaleString('ar-EG')} ر.س` },
    ];
    grid.rows = [
      { name: 'أحمد', department: 'الهندسة', salary: 42000 },
      { name: 'فاطمة', department: 'التصميم', salary: 38000 },
      { name: 'خالد', department: 'المبيعات', salary: 35000 },
      { name: 'سارة', department: 'الهندسة', salary: 44000 },
      { name: 'محمد', department: 'الدعم', salary: 31000 },
    ];

    container.addEventListener('control-change', ((e: CustomEvent) => {
      grid.dir = e.detail.allValues.rtl ? 'rtl' : 'ltr';
    }) as EventListener);
  }
```

**Logical column pinning:** Use `pinned: 'start'` and `pinned: 'end'` instead of `'left'`/`'right'` for direction-independent pinning. See the [Pinned Columns plugin](/grid/plugins/pinned-columns.md) for details.

---

## Configuration

### Column Inference

**Zero-config data display** — Just pass your data and the grid figures out the rest.

When you provide `rows` without defining `columns`, the grid automatically:

- Detects fields from the first row's property names
- Infers data types (`string`, `number`, `boolean`, `date`) from actual values
- Generates human-readable headers from field names (`firstName` → `First Name`)
- Applies appropriate sorting and formatting for each type

```typescript
grid.rows = myData; // That's it!
```

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

  const grid = queryGrid('#demo-column-inference');
  if (grid) {
    grid.rows = [
      { id: 1, firstName: 'Alice', lastName: 'Johnson', age: 32, active: true, startDate: '2022-03-15' },
      { id: 2, firstName: 'Bob', lastName: 'Smith', age: 28, active: false, startDate: '2023-01-10' },
      { id: 3, firstName: 'Carol', lastName: 'Williams', age: 45, active: true, startDate: '2021-07-22' },
      { id: 4, firstName: 'David', lastName: 'Brown', age: 36, active: true, startDate: '2020-11-05' },
      { id: 5, firstName: 'Eve', lastName: 'Davis', age: 29, active: false, startDate: '2024-02-18' },
    ];
  }
```

#### Merge mode — infer everything, customize one column

By default (`columnInference: 'auto'`), inference is **all-or-nothing**: the moment you declare a
single column, the grid renders **only** that column and skips inference entirely.

Opt into `columnInference: 'merge'` for a low-config workflow: the grid always infers the full
column set from your data, then **overlays** any explicitly provided columns matched by `field`.
Feed it data and it renders everything; if you disagree with how one column is rendered, add config
for _just that column_.

```typescript
grid.columnInference = 'merge';
grid.rows = employees; // every field renders, in data-key order
// Customize only the salary column — the rest stay inferred:
grid.columns = [{ field: 'salary', type: 'number', header: 'Salary (USD)' }];
```

Behaviour in `merge` mode:

- All data fields render in **data-key order** (from the first row), auto-typed.
- A provided column overlays **only its own field** (your config wins; inferred values such as
  `header`/`type` fill the gaps) and keeps its data position.
- A provided column for a field **absent from the data** is appended at the end as a computed
  column (e.g. an `actions` column).
- To hide a column, omit its key from the row objects (control via the data shape).

Set it via the `columnInference` prop, the `column-inference="merge"` attribute, or
`gridConfig.columnInference`. The default `'auto'` preserves the classic "declare a subset → show
only that subset" behaviour.

### Light DOM Columns

**Declarative configuration** — Define columns in HTML instead of JavaScript.

Use `<tbw-grid-column>` elements (or framework wrapper components) to declaratively define columns directly in your markup:

#### TypeScript

```html
<tbw-grid>
  <tbw-grid-column field="id" header="ID" width="80"></tbw-grid-column>
  <tbw-grid-column field="name" header="Full Name" sortable></tbw-grid-column>
  <tbw-grid-column field="email" header="Email Address"></tbw-grid-column>
</tbw-grid>
```

#### React

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

function EmployeeGrid({ rows }) {
  return (
    <DataGrid rows={rows}>
      <GridColumn field="id" header="ID" width={80} />
      <GridColumn field="name" header="Full Name" sortable />
      <GridColumn field="email" header="Email Address" />
    </DataGrid>
  );
}
```

#### Vue

```html
<script setup lang="ts">
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';
</script>

<template>
  <TbwGrid :rows="rows">
    <TbwGridColumn field="id" header="ID" :width="80" />
    <TbwGridColumn field="name" header="Full Name" sortable />
    <TbwGridColumn field="email" header="Email Address" />
  </TbwGrid>
</template>
```

#### Angular

```html
<tbw-grid [rows]="rows">
  <tbw-grid-column field="id" header="ID" width="80"></tbw-grid-column>
  <tbw-grid-column field="name" header="Full Name" sortable></tbw-grid-column>
  <tbw-grid-column field="email" header="Email Address"></tbw-grid-column>
</tbw-grid>
```

This approach is ideal for:

- **Static layouts** where columns don't change at runtime
- **Server-rendered pages** where HTML is generated on the server
- **Template-driven frameworks** like Angular or Vue that prefer declarative syntax
- **Quick prototyping** without writing JavaScript

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

  const grid = queryGrid('#demo-light-dom-columns');
  if (grid) {
    grid.rows = [
      { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering' },
      { id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Sales' },
      { id: 3, name: 'Carol Williams', email: 'carol@example.com', department: 'Marketing' },
      { id: 4, name: 'David Brown', email: 'david@example.com', department: 'Engineering' },
      { id: 5, name: 'Eve Davis', email: 'eve@example.com', department: 'HR' },
    ];
  }
```

#### Initial column ordering with the `order` attribute

Use the `order` attribute to control the initial position of columns when they are first rendered. This is useful for reordering columns declaratively without JavaScript:

#### TypeScript

```html
<tbw-grid>
  <tbw-grid-column field="id" header="ID" order="2"></tbw-grid-column>
  <tbw-grid-column field="name" header="Full Name" order="0"></tbw-grid-column>
  <tbw-grid-column field="email" header="Email Address" order="1"></tbw-grid-column>
</tbw-grid>
```
The columns will appear in order: `name`, `email`, `id`.

#### React

```tsx
<DataGrid rows={rows}>
  <GridColumn field="id" header="ID" order={2} />
  <GridColumn field="name" header="Full Name" order={0} />
  <GridColumn field="email" header="Email Address" order={1} />
</DataGrid>
```

#### Vue

```html
<TbwGrid :rows="rows">
  <TbwGridColumn field="id" header="ID" :order="2" />
  <TbwGridColumn field="name" header="Full Name" :order="0" />
  <TbwGridColumn field="email" header="Email Address" :order="1" />
</TbwGrid>
```

#### Angular

```html
<tbw-grid [rows]="rows">
  <tbw-grid-column field="id" header="ID" order="2"></tbw-grid-column>
  <tbw-grid-column field="name" header="Full Name" order="0"></tbw-grid-column>
  <tbw-grid-column field="email" header="Email Address" order="1"></tbw-grid-column>
</tbw-grid>
```

Columns without an `order` attribute keep their relative order; columns with `order` are inserted at their target indices. The `order` attribute only affects the **initial render**; user interactions (drag-to-reorder via the [Reorder plugin](/grid/plugins/reorder-columns.md)) take precedence after that. Use [`resetColumnOrder()`](/grid/api/plugins/reorder-columns/methods/resetcolumnorder/) to restore the order-attribute positioning.

**Combining with `columnInference: 'merge'`** — The `order` attribute becomes especially powerful in `merge` mode. With inference enabled, the grid automatically displays all data fields, and you can use light-DOM `<tbw-grid-column>` elements to customize **only the columns you care about** — adding a custom `header`, overriding the `type`, adjusting `width`, and positioning via `order`, all without declaring the entire column set.

### Configuration Reference

The grid is configured through the `gridConfig` property (or individual shorthand properties). The table below covers the most common options — see [`GridConfig`](/grid/api/core/interfaces/gridconfig.md) for the full, type-checked reference.

| Property | Type | Description |
|----------|------|-------------|
| `columns` | [`ColumnConfig[]`](/grid/api/core/interfaces/columnconfig.md) | Column definitions |
| `rows` | `any[]` | Row data array (top-level grid prop, not on `GridConfig`) |
| `fitMode` | [`FitMode`](/grid/api/core/types/fitmode.md) | How columns fill available width (`'stretch'` or `'fixed'`) |
| `columnInference` | [`ColumnInferenceMode`](/grid/api/core/types/columninferencemode.md) | How inference combines with provided columns (`'auto'` default, or `'merge'`) |
| `sortable` | `boolean` | Grid-wide sort toggle (default `true`) |
| `resizable` | `boolean` | Grid-wide resize toggle (default `true`) |
| `initialSort` | `{ field, direction }` | Sort applied on first render (`'asc'` or `'desc'`) |
| `rowHeight` | `number \| (row, index) => number \| undefined` | Fixed or variable row heights |
| `getRowId` | `(row) => string` | Unique row identity function |
| `rowClass` | `(row) => string \| string[]` | Dynamic row CSS classes |
| `typeDefaults` | `Record<string, `[`TypeDefault`](/grid/api/core/interfaces/typedefault.md)`>` | Default format/renderer per column `type` |
| `plugins` | [`GridPlugin[]`](/grid/api/plugin-development/interfaces/gridplugin.md) | Plugin instances |
| `features` | [`Partial<FeatureConfig>`](/grid/api/core/interfaces/featureconfig.md) | Declarative feature config (alternative to `plugins`) |
| `columnState` | [`GridColumnState`](/grid/api/core/interfaces/gridcolumnstate.md) | Saved column state to restore on init |
| `icons` | [`GridIcons`](/grid/api/core/interfaces/gridicons.md) | Grid-wide icon overrides |
| `animation` | [`AnimationConfig`](/grid/api/core/interfaces/animationconfig.md) | Animation defaults (expand/collapse, reorder, etc.) |
| `loadingRenderer` | [`LoadingRenderer`](/grid/api/core/types/loadingrenderer.md) | Custom loading indicator |
| `emptyRenderer` | [`EmptyRenderer`](/grid/api/core/types/emptyrenderer.md) ` \| null` | Custom no-rows message (set `null` to suppress) |
| `emptyOverlay` | `'rows' \| 'grid'` | Where to mount the empty overlay (default `'rows'`) |
| `sortHandler` | [`SortHandler`](/grid/api/core/types/sorthandler.md) | Low-level sort engine override (prefer `sortComparator` per-column) |
| `gridAriaLabel` | `string` | Accessible label for the grid (`aria-label`) |
| `gridAriaDescribedBy` | `string` | ID of an element that describes the grid (`aria-describedby`) |
| `a11y` | [`A11yConfig`](/grid/api/core/interfaces/a11yconfig.md) | Screen reader announcement messages and toggle |

**Precedence (low → high):**

1. `gridConfig` prop (base)
2. Light DOM elements (declarative)
3. `columns` prop (direct array)
4. Inferred columns (auto-detected from first row)
5. Individual props (`fitMode`) — highest

> In `columnInference: 'merge'` mode the order differs: the grid infers the full column set first,
> then overlays the merged provided columns by `field` (provided wins, in data-key order).

### System Columns

Some columns exist to support grid behaviour rather than to display user data — a row-action menu, a status indicator, a row number, the selection checkbox the grid injects for you. Mark any column with `utility: true` and the grid treats it as a **system column**: rendered normally, but excluded from chooser, reorder, print, export, clipboard, and selection.

```ts
{
  field: '__actions',
  header: '',
  width: 80,
  utility: true,                   // ← marks this as a system column
  resizable: false,
  sortable: false,
  filterable: false,
  viewRenderer: ({ row }) => createActionsButton(row),
}
```

**What `utility: true` does:**

| Surface | Behaviour |
| --- | --- |
| Visibility panel | Not listed — users cannot toggle it on/off |
| Column reorder | Locked in place |
| Print | Hidden by `PrintPlugin` (override with `printHidden: false`) |
| Clipboard copy | Skipped by `ClipboardPlugin` |
| Export (CSV/JSON/XLSX) | Skipped by `ExportPlugin` |
| Range / row selection | Click does not extend selection |
| Filter UI | No filter button, no filter model entry |
| Cell rendering | **Rendered normally** — your renderer runs |

> **Naming convention:** Prefix the field with `__` (e.g. `__actions`, `__status`) so it cannot collide with a real data field.

**Built-in system columns** the grid synthesises automatically use the same flag: `SelectionPlugin` checkbox (`__tbw_checkbox`), `MasterDetailPlugin` / `TreePlugin` / `GroupingRowsPlugin` expander (`__tbw_expander`), `RowDragDropPlugin` drag handle.

**Related flags** when you want finer control: `lockPosition` (reorder only), `lockVisible` (chooser only), `printHidden` (print only), `hidden` (entirely hidden).

---

## Presentation

### Value Accessors

**Resolve a cell's value when it isn't a plain field read.** By default the grid reads `row[field]`. A `valueAccessor` is only relevant when that's not the right value — typically because the cell is computed from multiple row fields, plucked from a nested structure, or derived from something outside the row.

You _could_ achieve the visual result with a custom `renderer` or `format` function alone, but you'd lose every other feature that depends on knowing what the cell's value actually is — sorting (unless you also write a `sortComparator`), filtering (unless you also write a `filterValue`), grouping, aggregations, copy-to-clipboard, and exports (CSV / Excel) would all see `undefined` or the raw `row[field]`. `valueAccessor` plugs the value in once and every consumer stays consistent.

```typescript
grid.columns = [
  // Computed from sibling fields
  {
    field: 'total',
    headerName: 'Total',
    valueAccessor: ({ row }) => row.qty * row.price,
  },

  // Pluck from a nested array
  {
    field: 'lastShipmentDate',
    headerName: 'Last Shipment',
    valueAccessor: ({ row }) => row.shipments?.find((s) => s.kind === 'BL')?.date,
    format: (v) => (v ? new Date(v).toLocaleDateString() : '—'),
  },

  // Normalize / coerce
  {
    field: 'name',
    valueAccessor: ({ row }) => `${row.firstName} ${row.lastName}`.trim(),
  },
];
```

**The accessor receives** `{ row, column, rowIndex }` and returns the column's typed value. Use it whenever the *cell value* isn't a plain field read.

#### Precedence

For each operation, the grid looks for a column-level override first, then falls back to the accessor, then to the field:

| Operation | Order |
|---|---|
| **Sort comparison** | `sortComparator` → `valueAccessor` → `row[field]` |
| **Filter value** | `filterValue` → `valueAccessor` → `row[field]` |
| **Group key & aggregations** (`sum`, `avg`, `min`, `max`, `first`, `last`) | `valueAccessor` → `row[field]` |
| **Copy / export (CSV, Excel)** | `valueAccessor` → `row[field]` (then `format` if present) |
| **Display** (`format` / `renderer`) | `valueAccessor` → `row[field]` |

This means you write the lookup logic once and every consumer stays consistent.

#### Caching & invalidation

Accessor results are cached per `(row, column.field)` in a `WeakMap` keyed on the row object — so an expensive accessor (e.g. `array.find`) runs once per row regardless of how many features read it. Primitive rows bypass the cache.

The cache is invalidated automatically when:

- A row reference changes (immutable updates — recommended pattern).
- You call `RowManager.updateRow` / `updateRows` / `applyTransaction` (in-place edits).
- The Editing plugin commits a value.

If you mutate row data outside of those paths, call `invalidateAccessorCache(row?, field?)` manually:

```typescript
import { invalidateAccessorCache } from '@toolbox-web/grid';

row.shipments.push(newShipment);
invalidateAccessorCache(row, 'lastShipmentDate'); // narrow scope
// or invalidateAccessorCache(row);                // all fields on this row
// or invalidateAccessorCache();                   // entire cache
```

:::tip[Reactive accessors (signals / refs / state)]
If your accessor closes over reactive state — an Angular `signal`, a Vue `ref`, a React store value — the cache won't notice when that state changes (the row reference is still the same). Pair the accessor with a framework-level effect that invalidates the cache and asks the grid to re-render:

#### Angular

```typescript
import { effect, inject } from '@angular/core';
import { invalidateAccessorCache } from '@toolbox-web/grid';

fxRate = signal(1.0);

columns = [{
  field: 'totalUsd',
  valueAccessor: ({ row }) => row.totalEur * this.fxRate(),
}];

constructor() {
  effect(() => {
    this.fxRate(); // tracked dependency
    invalidateAccessorCache();
    this.grid()?.requestRender();
  });
}
```

#### Vue

```typescript
import { ref, watch } from 'vue';
import { invalidateAccessorCache } from '@toolbox-web/grid';

const fxRate = ref(1.0);

const columns = [{
  field: 'totalUsd',
  valueAccessor: ({ row }) => row.totalEur * fxRate.value,
}];

watch(fxRate, () => {
  invalidateAccessorCache();
  gridRef.value?.requestRender();
});
```

#### React

```typescript
import { useEffect } from 'react';
import { invalidateAccessorCache } from '@toolbox-web/grid';

const [fxRate, setFxRate] = useState(1.0);

const columns = useMemo(() => [{
  field: 'totalUsd',
  valueAccessor: ({ row }) => row.totalEur * fxRate,
}], [fxRate]);

useEffect(() => {
  invalidateAccessorCache();
  gridRef.current?.requestRender();
}, [fxRate]);
```

:::

#### Editing

Accessors are **read-only**. Cells driven by a `valueAccessor` cannot currently be written back through the Editing plugin (a matching `valueSetter` API is planned). For now, gate them with `editable: false` or omit `editable`.

---

### Formatters

**Transform how values are displayed** — Formatters convert raw data values into user-friendly text.

A formatter is a function that receives the cell value and returns a display string:

```typescript
grid.columns = [
  // Currency
  { field: 'salary', format: (v) => `$${v.toLocaleString()}` },

  // Date
  { field: 'hireDate', format: (v) => new Date(v).toLocaleDateString() },

  // Percentage
  { field: 'progress', format: (v) => `${(v * 100).toFixed(1)}%` },

  // Prefix
  { field: 'id', format: (v) => `#${v}` },
];
```

### Row Styling

**Style entire rows** based on data using the `rowClass` callback:

```typescript
grid.gridConfig = {
  rowClass: (row) => (row.status === 'inactive' ? 'row-inactive' : ''),
};
```

Then define the CSS class in your stylesheet:

```css
.row-inactive { opacity: 0.5; }
```

### Cell Styling

**Style individual cells** based on their value using `cellClass` on a column:

```typescript
grid.columns = [
  {
    field: 'score',
    cellClass: (value) => {
      if (value >= 90) return 'cell-success';
      if (value < 50) return 'cell-danger';
      return '';
    },
  },
];
```

:::tip
You can combine `rowClass` and `cellClass`. When styles conflict, cell styles win because cells are children of rows in the DOM hierarchy.
:::

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

  interface Employee {
    id: number;
    name: string;
    status: string;
    score: number;
    department: string;
  }

  const grid = queryGrid<Employee>('#demo-row-cell-styling');
  if (grid) {
    grid.gridConfig = {
      rowClass: (row) => row.status === 'inactive' ? 'row-inactive' : '',
      columns: [
        { field: 'id', header: 'ID', width: 60 },
        { field: 'name', header: 'Name', width: 140 },
        { field: 'department', header: 'Department', width: 120 },
        { field: 'status', header: 'Status', width: 100 },
        {
          field: 'score',
          header: 'Score',
          width: 100,
          align: 'right',
          cellClass: (value) => {
            if ((value as number) >= 90) return 'cell-success';
            if ((value as number) < 50) return 'cell-danger';
            if ((value as number) < 70) return 'cell-warning';
            return '';
          },
        },
      ],
    };
    grid.rows = [
      { id: 1, name: 'Alice', status: 'active', score: 95, department: 'Engineering' },
      { id: 2, name: 'Bob', status: 'inactive', score: 42, department: 'Sales' },
      { id: 3, name: 'Carol', status: 'active', score: 78, department: 'Marketing' },
      { id: 4, name: 'David', status: 'active', score: 35, department: 'Engineering' },
      { id: 5, name: 'Eve', status: 'active', score: 91, department: 'HR' },
      { id: 6, name: 'Frank', status: 'inactive', score: 67, department: 'Finance' },
    ];
  }
```

### Renderers

**Full control over cell content** — Renderers let you create custom HTML elements for cells.

While formatters return plain text, renderers return DOM elements. Use renderers when you need:

- **Custom components**: Checkboxes, badges, progress bars, buttons
- **Interactive elements**: Links, icons, action buttons
- **Rich formatting**: Multiple elements, images, complex layouts

#### TypeScript

```typescript
grid.columns = [
  {
    field: 'status',
    header: 'Status',
    renderer: (ctx) => {
      const badge = document.createElement('span');
      badge.className = `badge badge-${ctx.value}`;
      badge.textContent = ctx.value;
      return badge;
    },
  },
  {
    field: 'active',
    header: 'Active',
    renderer: (ctx) => {
      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.checked = !!ctx.value;
      checkbox.disabled = true;
      return checkbox;
    },
  },
];
```

#### React

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

function EmployeeGrid({ rows }) {
  return (
    <DataGrid rows={rows}>
      <GridColumn
        field="status"
        header="Status"
        renderer={({ value }) => (
          <span className={`badge badge-${value}`}>{value}</span>
        )}
      />
      <GridColumn
        field="active"
        header="Active"
        renderer={({ value }) => (
          <input type="checkbox" checked={!!value} disabled />
        )}
      />
    </DataGrid>
  );
}
```

#### Vue

```html
<script setup lang="ts">
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';
</script>

<template>
  <TbwGrid :rows="rows">
    <TbwGridColumn field="status" header="Status">
      <template #cell="{ value }">
        <span :class="`badge badge-${value}`">{{ value }}</span>
      </template>
    </TbwGridColumn>
    <TbwGridColumn field="active" header="Active">
      <template #cell="{ value }">
        <input type="checkbox" :checked="!!value" disabled />
      </template>
    </TbwGridColumn>
  </TbwGrid>
</template>
```

#### Angular

```typescript
import { Component } from '@angular/core';
import { Grid, TbwRenderer } from '@toolbox-web/grid-angular';

@Component({
  imports: [Grid, TbwRenderer],
  template: `
    <tbw-grid [rows]="rows" style="height: 400px; display: block">
      <tbw-grid-column field="status" header="Status">
        <span *tbwRenderer="let value" [class]="'badge badge-' + value">
          {{ value }}
        </span>
      </tbw-grid-column>
      <tbw-grid-column field="active" header="Active">
        <input *tbwRenderer="let value" type="checkbox" [checked]="!!value" disabled />
      </tbw-grid-column>
    </tbw-grid>
  `,
})
export class EmployeeGridComponent {
  rows = [/* ... */];
}
```

The renderer receives a [`CellRenderContext`](/grid/api/core/interfaces/cellrendercontext.md) — the cell value, the row object, the field name, and the column config.

:::note[Why no `rowIndex`?]
Renderer and editor contexts intentionally provide the **row object** instead of a row index. A `rowIndex` reflects the row's position in the grid's *current* sorted/filtered/grouped view — it silently becomes stale whenever the user sorts, filters, or reorders. The `row` object is a stable identity that remains valid regardless of view state.

If you need the visual index at a specific moment (e.g. for conditional styling of even/odd rows), derive it inside the renderer:

```typescript
renderer: (ctx) => {
  const rowIndex = grid.rows.indexOf(ctx.row);
  // Use rowIndex for one-time positional logic
}
```

For event-driven use cases, click and activation events (`cell-click`, `row-click`, `cell-activate`) already include `rowIndex` in their detail payload.
:::

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

  interface Employee {
    id: number;
    name: string;
    status: string;
    active: boolean;
    rating: number;
    salary: number;
  }

  const grid = queryGrid<Employee>('#demo-custom-renderers');
  if (grid) {
    grid.columns = [
      { field: 'id', header: 'ID', width: 60 },
      { field: 'name', header: 'Name', width: 140 },
      {
        field: 'status',
        header: 'Status',
        width: 110,
        renderer: ({ value, cellEl }) => {
          const colors: Record<string, string> = {
            active: '#16a34a', inactive: '#dc2626', 'on-leave': '#d97706',
          };
          const badge = document.createElement('span');
          badge.style.cssText = `
            display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em;
            background: ${colors[value as string] || '#888'}22; color: ${colors[value as string] || '#888'};
            font-weight: 600;
          `;
          badge.textContent = (value as string).charAt(0).toUpperCase() + (value as string).slice(1);
          return badge;
        },
      },
      {
        field: 'active',
        header: 'Active',
        width: 80,
        align: 'center',
        renderer: ({ value }) => {
          const checkbox = document.createElement('input');
          checkbox.type = 'checkbox';
          checkbox.checked = !!value;
          checkbox.disabled = true;
          return checkbox;
        },
      },
      {
        field: 'rating',
        header: 'Rating',
        width: 100,
        align: 'center',
        renderer: ({ value }) => '★'.repeat(value as number) + '☆'.repeat(5 - (value as number)),
      },
      {
        field: 'salary',
        header: 'Salary',
        width: 120,
        align: 'right',
        format: (value) => `$${(value as number).toLocaleString()}`,
      },
    ];
    grid.rows = [
      { id: 1, name: 'Alice Johnson', status: 'active', active: true, rating: 5, salary: 95000 },
      { id: 2, name: 'Bob Smith', status: 'inactive', active: false, rating: 3, salary: 72000 },
      { id: 3, name: 'Carol Williams', status: 'on-leave', active: true, rating: 4, salary: 88000 },
      { id: 4, name: 'David Brown', status: 'active', active: true, rating: 5, salary: 105000 },
      { id: 5, name: 'Eve Davis', status: 'active', active: false, rating: 2, salary: 65000 },
    ];
  }
```

#### Light DOM renderers

You can also define a cell renderer **declaratively in HTML** — no JavaScript required. Nest a
`<tbw-grid-column-view>` element inside a `<tbw-grid-column>`; its content becomes the cell template.
Use `{{ value }}` to interpolate the cell value (and `{{ row.field }}` for other row fields):

```html
<tbw-grid column-inference="merge">
  <tbw-grid-column field="status">
    <tbw-grid-column-view>
      <span class="badge badge-{{ value }}">{{ value }}</span>
    </tbw-grid-column-view>
  </tbw-grid-column>
</tbw-grid>
```

The companion light-DOM templates are `<tbw-grid-column-editor>` (custom editor, requires the
[Editing plugin](/grid/plugins/editing.md)) and `<tbw-grid-column-header>` (custom header cell). See the
[API reference](/grid/api-reference.md#column-elements) for the full list. The framework adapters
expose the same capability through their own template syntax — `#cell` slots in Vue, `*tbwRenderer`
in Angular, and `renderer` props in React (shown in the tabs above).

#### Renderer security: avoid `innerHTML`

Renderers run with full DOM access. The grid does not sandbox what you do inside one, so user-supplied data must be inserted safely. The hazard is `innerHTML`:

```typescript
// ✅ Safe — textContent escapes HTML automatically
renderer: (ctx) => {
  const span = document.createElement('span');
  span.textContent = ctx.value;
  return span;
};

// ❌ XSS vulnerability — user input rendered as HTML
renderer: (ctx) => {
  const div = document.createElement('div');
  div.innerHTML = ctx.value; // If value contains <script>, it executes
  return div;
};
```

If you genuinely need to inject HTML (e.g. rendering a server-trusted markdown snippet), sanitize first with a library like [DOMPurify](https://github.com/cure53/DOMPurify) before assigning to `innerHTML`.

:::note[String-returning renderers are auto-sanitized]
When a cell renderer or group header renderer returns an **HTML string** (rather than an `HTMLElement` / `Node` / framework component), the grid runs the string through an internal sanitizer that strips `<script>` elements and event-handler attributes (`onclick`, `onerror`, etc.) before inserting it into the DOM. This is defense-in-depth — always validate your data upstream, but accidental injection through these renderer paths will not execute. Renderers that return `HTMLElement` / `Node` / framework components are inserted as-is and are not sanitized; the `textContent` rule above is your responsibility.
:::

### Choosing: Formatter vs Renderer

Now that you've seen both, here's a quick reference for picking the right one:

| | Formatter (`format`) | Renderer (`renderer`) |
|---|---|---|
| **Returns** | String | DOM element |
| **Use for** | Currency, dates, percentages, text formatting | Checkboxes, badges, buttons, links, images |
| **Performance** | Faster (text only) | Slower (DOM creation) |
| **Interactivity** | None (display only) | Full (event listeners, components) |

**Rule of thumb:** If you only need to change how a value *looks*, use a `format` function. If you need interactive elements or custom HTML structure, use a `renderer`.

### Row Animation

The grid provides a built-in row animation API for highlighting changes, insertions, and removals with visual feedback.

| Method | Description | CSS Variable |
|--------|-------------|--------------|
| `animateRow(i, 'change')` | Flash highlight for data changes | `--tbw-row-change-duration` (500ms) |
| `insertRow(i, row)` | Slide-in animation for new rows | `--tbw-row-insert-duration` (300ms) |
| `removeRow(i)` | Fade-out animation for removed rows | `--tbw-row-remove-duration` (200ms) |
| `applyTransaction(tx)` | Animates add/update/remove in one pass | All of the above |

```typescript
// Highlight a row after updating
grid.rows[5].status = 'updated';
grid.animateRow(5, 'change');

// Animate by row ID (stable when sorted/filtered)
grid.animateRowById(data.id, 'change');

// Animate multiple rows at once
grid.animateRows([0, 2, 5], 'change');
```

**Customize appearance:**

```css
tbw-grid {
  --tbw-row-change-duration: 750ms;
  --tbw-row-change-color: rgba(34, 197, 94, 0.25);
}
```

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

  interface Item { id: number; name: string; status: string; }

  const grid = queryGrid<Item>('#demo-row-animation');

  if (grid) {
    const container = document.querySelector('.grid-demo');
    let nextId = 6;
    const data: Item[] = [
      { id: 1, name: 'Alpha', status: 'active' },
      { id: 2, name: 'Bravo', status: 'active' },
      { id: 3, name: 'Charlie', status: 'pending' },
      { id: 4, name: 'Delta', status: 'active' },
      { id: 5, name: 'Echo', status: 'inactive' },
    ];

    grid.columns = [
      { field: 'id', header: 'ID', width: 60 },
      { field: 'name', header: 'Name', width: 140 },
      { field: 'status', header: 'Status', width: 120 },
    ];
    grid.rows = data;

    container.addEventListener('click', (e) => {
      const action = (e.target as HTMLElement).dataset.animAction;
      if (!action) return;

      if (action === 'change') {
        const idx = Math.floor(Math.random() * grid.rows.length);
        grid.animateRow(idx, 'change');
      } else if (action === 'insert') {
        const id = nextId++;
        const row = { id, name: `New-${id}`, status: 'new' };
        grid.insertRow(grid.rows.length, row);
      } else if (action === 'remove' && grid.rows.length > 1) {
        grid.removeRow(grid.rows.length - 1);
      }
    });
  }
```

:::note
When using the [**EditingPlugin**](/grid/plugins/editing.md), rows are automatically animated with `'change'` after a successful edit commit.
:::

### Type-Level Defaults

Define default formatters and renderers for all columns of a specific `type`:

#### TypeScript

```typescript
grid.gridConfig = {
  typeDefaults: {
    currency: {
      format: (value) =>
        new Intl.NumberFormat('en-US', {
          style: 'currency',
          currency: 'USD',
        }).format(value),
    },
    boolean: {
      renderer: (ctx) => {
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.checked = !!ctx.value;
        cb.disabled = true;
        return cb;
      },
    },
    status: {
      renderer: (ctx) => {
        const badge = document.createElement('span');
        badge.className = `status-badge status-${ctx.value}`;
        badge.textContent = ctx.value;
        return badge;
      },
    },
  },
  columns: [
    { field: 'salary', type: 'currency' },
    { field: 'active', type: 'boolean' },
    { field: 'status', type: 'status' },
  ],
};
```

#### React

```tsx
import { GridTypeProvider, DataGrid, GridColumn } from '@toolbox-web/grid-react';
import type { TypeDefaultsMap } from '@toolbox-web/grid-react';

const typeDefaults: TypeDefaultsMap = {
  currency: {
    format: (value) =>
      new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value),
  },
  boolean: {
    renderer: ({ value }) => <input type="checkbox" checked={!!value} disabled />,
  },
  status: {
    renderer: ({ value }) => (
      <span className={`status-badge status-${value}`}>{value}</span>
    ),
  },
};

function App() {
  return (
    <GridTypeProvider defaults={typeDefaults}>
      <DataGrid rows={rows}>
        <GridColumn field="salary" type="currency" />
        <GridColumn field="active" type="boolean" />
        <GridColumn field="status" type="status" />
      </DataGrid>
    </GridTypeProvider>
  );
}
```

#### Vue

```html
<script setup lang="ts">
import { h } from 'vue';
import { GridTypeProvider, TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';
import CheckboxCell from './CheckboxCell.vue';
import StatusBadge from './StatusBadge.vue';

const typeDefaults = {
  currency: {
    format: (value) =>
      new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value),
  },
  boolean: {
    renderer: ({ value }) => h(CheckboxCell, { checked: !!value }),
  },
  status: {
    renderer: ({ value }) => h(StatusBadge, { status: value }),
  },
};
</script>

<template>
  <GridTypeProvider :defaults="typeDefaults">
    <TbwGrid :rows="rows">
      <TbwGridColumn field="salary" type="currency" />
      <TbwGridColumn field="active" type="boolean" />
      <TbwGridColumn field="status" type="status" />
    </TbwGrid>
  </GridTypeProvider>
</template>
```

#### Angular

```typescript
import { Component } from '@angular/core';
import { Grid, provideGridTypeDefaults } from '@toolbox-web/grid-angular';
import { CheckboxCellComponent } from './checkbox-cell.component';
import { StatusBadgeComponent } from './status-badge.component';

@Component({
  imports: [Grid],
  providers: [
    provideGridTypeDefaults({
      currency: {
        format: (value) =>
          new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value),
      },
      boolean: { renderer: CheckboxCellComponent },
      status: { renderer: StatusBadgeComponent },
    }),
  ],
  template: `
    <tbw-grid [rows]="rows" [columns]="columns" />
  `,
})
export class AppComponent {
  columns = [
    { field: 'salary', type: 'currency' },
    { field: 'active', type: 'boolean' },
    { field: 'status', type: 'status' },
  ];
}
```

Type defaults reduce repetition when many columns share the same presentation. Column-level `format` or `renderer` overrides type defaults when both are specified.

### Custom Header Renderers

Customize column header cells using `headerLabelRenderer` or `headerRenderer`.

:::tip
The framework adapters (React, Vue, Angular) bridge `headerRenderer` and `headerLabelRenderer` — you can use React JSX, Vue components/render functions, or Angular component classes just like cell renderers. Vanilla DOM functions also work.
:::

**`headerLabelRenderer`** — Modify just the label portion of the header (sort icons and filter buttons are still managed by the grid):

#### TypeScript

```typescript
{
  field: 'name',
  header: 'Name',
  headerLabelRenderer: ({ value }) => {
    const span = document.createElement('span');
    span.innerHTML = `${value} <span style="color:red">*</span>`;
    return span;
  },
}
```

#### React

```tsx
// In GridConfig or ColumnConfig
{
  field: 'name',
  header: 'Name',
  headerLabelRenderer: ({ value }) => (
    <span>{value} <span style={{ color: 'red' }}>*</span></span>
  ),
}
```

#### Vue

```ts
// In GridConfig or ColumnConfig
{
  field: 'name',
  header: 'Name',
  headerLabelRenderer: ({ value }) =>
    h('span', [value, h('span', { style: 'color:red' }, ' *')]),
}
```

#### Angular

```typescript
// Using a component class in ColumnConfig
{
  field: 'name',
  header: 'Name',
  headerLabelRenderer: RequiredLabelComponent,
}

// RequiredLabelComponent
@Component({
  selector: 'app-required-label',
  template: `{{ value() }} <span style="color:red">*</span>`,
})
export class RequiredLabelComponent {
  value = input<string>();
  column = input<unknown>();
}
```

**`headerRenderer`** — Full control over the entire header cell. You are responsible for rendering sort icons using `ctx.renderSortIcon()`:

#### TypeScript

```typescript
{
  field: 'email',
  header: 'Email',
  headerRenderer: (ctx) => {
    const wrapper = document.createElement('div');
    wrapper.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%';

    const icon = document.createElement('span');
    icon.textContent = '📧';
    wrapper.appendChild(icon);

    const label = document.createElement('span');
    label.textContent = ctx.value;
    label.style.flex = '1';
    wrapper.appendChild(label);

    const sortIcon = ctx.renderSortIcon();
    if (sortIcon) wrapper.appendChild(sortIcon);
    return wrapper;
  },
}
```

#### React

```tsx
// In GridConfig or ColumnConfig
// Use ctx.sortState to render your own sort indicator in JSX
{
  field: 'email',
  header: 'Email',
  headerRenderer: (ctx) => (
    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', width: '100%' }}>
      <span>📧</span>
      <span style={{ flex: 1 }}>{ctx.value}</span>
      {ctx.sortState && (
        <span className="sort-icon">
          {ctx.sortState === 'asc' ? '▲' : '▼'}
        </span>
      )}
    </div>
  ),
}
```

#### Vue

```ts
// In GridConfig or ColumnConfig
// Use ctx.sortState to render your own sort indicator
{
  field: 'email',
  header: 'Email',
  headerRenderer: (ctx) =>
    h('div', { style: 'display:flex;align-items:center;gap:6px;width:100%' }, [
      h('span', '📧'),
      h('span', { style: 'flex:1' }, ctx.value),
      ctx.sortState
        ? h('span', { class: 'sort-icon' },
            ctx.sortState === 'asc' ? '▲' : '▼')
        : null,
    ]),
}
```

#### Angular

```typescript
// Using a component class in ColumnConfig
{
  field: 'email',
  header: 'Email',
  headerRenderer: EmailHeaderComponent,
}

// EmailHeaderComponent
@Component({
  selector: 'app-email-header',
  template: `
    <div style="display:flex;align-items:center;gap:6px;width:100%">
      <span>📧</span>
      <span style="flex:1">{{ value() }}</span>
    </div>
  `,
})
export class EmailHeaderComponent {
  value = input<string>();
  column = input<unknown>();
  sortState = input<'asc' | 'desc' | null>();
  filterActive = input<boolean>();
  renderSortIcon = input<() => HTMLElement | null>();
  renderFilterButton = input<() => HTMLElement | null>();
}
```

The [`HeaderCellContext`](/grid/api/core/interfaces/headercellcontext.md) gives you the label text, the column config, the current sort/filter state, and helpers to render the built-in sort icon and filter button when you want to keep them.

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

  const grid = queryGrid('#demo-header-renderers');

  if (grid) {
    grid.columns = [
      { field: 'id', header: 'ID', sortable: true, width: 60 },
      {
        field: 'name',
        header: 'Name',
        sortable: true,
        resizable: true,
        headerLabelRenderer: ({ value }) => {
          const span = document.createElement('span');
          span.innerHTML = `${value} <span style="color:red;font-weight:bold;">*</span>`;
          return span;
        },
      },
      {
        field: 'email',
        header: 'Email',
        sortable: true,
        resizable: true,
        headerRenderer: (ctx) => {
          const wrapper = document.createElement('div');
          wrapper.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;';
          const icon = document.createElement('span');
          icon.textContent = '📧';
          wrapper.appendChild(icon);
          const label = document.createElement('span');
          label.textContent = ctx.value;
          label.style.flex = '1';
          wrapper.appendChild(label);
          const sortIcon = ctx.renderSortIcon();
          if (sortIcon) wrapper.appendChild(sortIcon);
          return wrapper;
        },
      },
      { field: 'score', header: 'Score', sortable: true, type: 'number', width: 80 },
    ];

    grid.rows = [
      { id: 1, name: 'Alice', email: 'alice@example.com', score: 85 },
      { id: 2, name: 'Bob', email: 'bob@example.com', score: 72 },
      { id: 3, name: 'Carol', email: 'carol@example.com', score: 91 },
      { id: 4, name: 'Dan', email: 'dan@example.com', score: 68 },
      { id: 5, name: 'Eve', email: 'eve@example.com', score: 95 },
    ];
  }
```

---

## Column State Persistence

The grid tracks column state — widths, sort direction, order, and visibility. You can save, load, and reset this state for user personalization.

### What State Contains

`getColumnState()` returns an array of [`GridColumnState`](/grid/api/core/interfaces/gridcolumnstate.md) objects — one entry per column capturing its field, current width, sort direction and priority, and visibility.

### Listening for Changes

The `column-state-change` event fires whenever the user resizes, reorders, sorts, or hides/shows columns:

#### TypeScript

```typescript
grid.on('column-state-change', (state) => {
  localStorage.setItem('my-grid-state', JSON.stringify(state));
});
```

#### React

```tsx
<DataGrid
  rows={rows}
  onColumnStateChange={(e) => {
    localStorage.setItem('my-grid-state', JSON.stringify(e.detail));
  }}
/>
```

#### Vue

```html
<template>
  <TbwGrid :rows="rows" @column-state-change="onColumnStateChange" />
</template>

<script setup lang="ts">
function onColumnStateChange(e: CustomEvent<GridColumnState[]>) {
  localStorage.setItem('my-grid-state', JSON.stringify(e.detail));
}
</script>
```

#### Angular

```html
<tbw-grid [rows]="rows" (column-state-change)="onColumnStateChange($event)" />
```

```typescript
onColumnStateChange(e: CustomEvent<GridColumnState[]>) {
  localStorage.setItem('my-grid-state', JSON.stringify(e.detail));
}
```

### Applying Saved State

Restore a previously saved state using `applyColumnState()`:

```typescript
const saved = localStorage.getItem('my-grid-state');
if (saved) {
  grid.applyColumnState(JSON.parse(saved));
}
```

### Resetting State

Re-assign the original column definitions to reset to defaults:

```typescript
grid.columns = [...originalColumns];
```

```ts
// ColumnStatePersistenceDemo.astro
  import '@toolbox-web/grid';
import type { ColumnConfig, GridColumnState } from '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

  const STORAGE_KEY = 'tbw-demo-column-state';

  const container = document.getElementById('demo-column-state')?.closest('.grid-demo');
  if (container) {
    const grid = queryGrid('tbw-grid', container)!;

    const status = container.querySelector<HTMLElement>('[data-status]')!;
    const log = container.querySelector<HTMLElement>('[data-log]')!;

    const defaultColumns: ColumnConfig[] = [
      { field: 'id', header: 'ID', width: 60 },
      { field: 'name', header: 'Name', width: 160 },
      { field: 'department', header: 'Department', width: 140 },
      { field: 'salary', header: 'Salary', type: 'number', width: 100 },
      { field: 'location', header: 'Location', width: 130 },
    ];

    grid.columns = defaultColumns;
    grid.sortable = true;
    grid.resizable = true;
    grid.rows = [
      { id: 1, name: 'Alice Johnson', department: 'Engineering', salary: 95000, location: 'New York' },
      { id: 2, name: 'Bob Smith', department: 'Marketing', salary: 72000, location: 'London' },
      { id: 3, name: 'Carol Williams', department: 'Engineering', salary: 108000, location: 'Berlin' },
      { id: 4, name: 'Dan Brown', department: 'Sales', salary: 67000, location: 'Tokyo' },
      { id: 5, name: 'Eve Davis', department: 'Marketing', salary: 81000, location: 'New York' },
      { id: 6, name: 'Frank Miller', department: 'Sales', salary: 73000, location: 'London' },
    ];

    function showStatus(msg: string) {
      status.textContent = msg;
      setTimeout(() => { status.textContent = ''; }, 2000);
    }

    function appendLog(msg: string) {
      log.textContent += msg + '\n';
      log.scrollTop = log.scrollHeight;
    }

    grid.on('column-state-change', (detail) => {
      appendLog(`State changed: ${JSON.stringify(detail).slice(0, 120)}…`);
    });

    container.querySelector('[data-save]')!.addEventListener('click', () => {
      const state = grid.getColumnState();
      localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
      showStatus('State saved!');
      appendLog('Saved: ' + JSON.stringify(state, null, 2));
    });

    container.querySelector('[data-load]')!.addEventListener('click', () => {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) { showStatus('No saved state found.'); return; }
      const state: GridColumnState[] = JSON.parse(raw);
      grid.applyColumnState(state);
      showStatus('State loaded!');
      appendLog('Loaded: ' + JSON.stringify(state, null, 2));
    });

    container.querySelector('[data-clear]')!.addEventListener('click', () => {
      localStorage.removeItem(STORAGE_KEY);
      showStatus('Saved state cleared.');
      appendLog('Cleared localStorage.');
    });

    container.querySelector('[data-reset]')!.addEventListener('click', () => {
      grid.columns = [...defaultColumns];
      showStatus('Grid reset to defaults.');
      appendLog('Reset to default columns.');
    });
  }
```

---

## Shell Components

:::caution[The shell moved to a plugin]
The shell (header bar, toolbar, and tool-panel sidebar) is **no longer part of core** — it is now the **[Shell plugin](/grid/plugins/shell.md)**. See that page for the full guide, demos, and configuration reference.

In **v2.x** the shell is **auto-registered**, so existing code keeps working without an import. That implicit auto-registration is **deprecated**: from **v3.0.0** the shell is opt-in — code that uses the header bar, toolbar, or tool panels **MUST** enable it explicitly:

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

grid.gridConfig = {
  features: { shell: { header: { title: 'Employee Data' } } },
};
```

The `grid.register*` / `openToolPanel` element delegates — and importing shell types from `@toolbox-web/grid` instead of `@toolbox-web/grid/plugins/shell` — are **deprecated** and removed at v3. The `shell` configuration object itself is augmented onto `gridConfig` by the plugin and is **not** deprecated. See [Migrating to the feature API](/grid/plugins/shell.md#migrating-to-the-feature-api).
:::

---

## Loading States

The grid supports loading indicators at three levels: grid-wide, per-row, and per-cell.

### API Reference

**Grid-level** — Shows a full overlay spinner:

```typescript
grid.loading = true;
// ... fetch data ...
grid.loading = false;
```

The `loading` attribute also works in HTML: `<tbw-grid loading></tbw-grid>`.

**Row-level** — Shows a spinner on a specific row (requires `getRowId`):

```typescript
grid.setRowLoading('row-42', true);
// ... update row ...
grid.setRowLoading('row-42', false);
```

**Cell-level** — Shows a spinner on a specific cell:

```typescript
grid.setCellLoading('row-42', 'status', true);
// ... update cell ...
grid.setCellLoading('row-42', 'status', false);
```

**Query and clear:**

```typescript
grid.isRowLoading('row-42');          // boolean
grid.isCellLoading('row-42', 'name'); // boolean
grid.clearAllLoading();               // remove all indicators
```

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

  interface Employee { id: string; name: string; email: string; department: string; }

  const container = document.getElementById('loading-states-container');
  const grid = queryGrid<Employee>('tbw-grid', container!);

  if (container && grid) {
    const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry'];
    const departments = ['Engineering', 'Sales', 'Marketing', 'HR', 'Finance'];
    const employees: Employee[] = Array.from({ length: 8 }, (_, i) => ({
      id: `emp-${i + 1}`,
      name: `${names[i % names.length]} ${i + 1}`,
      email: `${names[i % names.length].toLowerCase()}${i + 1}@example.com`,
      department: departments[i % departments.length],
    }));

    grid.gridConfig = { getRowId: (row) => row.id };
    grid.columns = [
      { field: 'id', header: 'ID', width: 80 },
      { field: 'name', header: 'Name' },
      { field: 'email', header: 'Email' },
      { field: 'department', header: 'Department' },
    ];
    grid.rows = employees;

    const hints: Record<string, string> = {
      grid: 'Click <strong>▶ Simulate</strong> to show full-grid loading overlay.',
      row: 'Click any <strong>row</strong> to trigger row loading.',
      cell: 'Click any <strong>cell</strong> to trigger cell loading.',
    };
    const hintEl = container.querySelector('.loading-hint');

    let currentMode: 'grid' | 'row' | 'cell' = 'grid';
    let currentAutoReset = true;

    container.addEventListener('control-change', ((e: CustomEvent) => {
      const v = e.detail.allValues as { mode: string; autoReset: boolean };
      currentMode = v.mode as 'grid' | 'row' | 'cell';
      currentAutoReset = v.autoReset;
      if (hintEl) hintEl.innerHTML = hints[currentMode] ?? '';
    }) as EventListener);

    // Row click → row loading
    grid.on('row-click', ({ row }) => {
      if (currentMode !== 'row') return;
      const timeout = currentAutoReset ? 1000 : null;
      if (timeout) {
        grid.setRowLoading(row.id, true);
        setTimeout(() => grid.setRowLoading(row.id, false), timeout);
      } else {
        grid.setRowLoading(row.id, !grid.isRowLoading(row.id));
      }
    });

    // Cell click → cell loading
    grid.on('cell-click', ({ row, column }) => {
      if (currentMode !== 'cell') return;
      const timeout = currentAutoReset ? 1000 : null;
      if (timeout) {
        grid.setCellLoading(row.id, column.field, true);
        setTimeout(() => grid.setCellLoading(row.id, column.field, false), timeout);
      } else {
        grid.setCellLoading(row.id, column.field, !grid.isCellLoading(row.id, column.field));
      }
    });

    // Simulate button
    container.querySelector('[data-loading="simulate"]')?.addEventListener('click', async () => {
      grid.clearAllLoading();
      const timeout = currentAutoReset ? 1000 : null;

      if (currentMode === 'grid') {
        grid.loading = true;
        if (timeout) setTimeout(() => { grid.loading = false; }, timeout);
      } else if (currentMode === 'row') {
        for (const emp of employees) {
          grid.setRowLoading(emp.id, true);
          await new Promise((r) => setTimeout(r, 100));
        }
        if (timeout) setTimeout(() => grid.clearAllLoading(), timeout);
      } else if (currentMode === 'cell') {
        for (const emp of employees) {
          grid.setCellLoading(emp.id, 'email', true);
          await new Promise((r) => setTimeout(r, 100));
        }
        if (timeout) setTimeout(() => grid.clearAllLoading(), timeout);
      }
    });
  }
```

### Custom Loading Renderer

Replace the default spinner with a custom element using `loadingRenderer`:

#### TypeScript

```typescript
grid.gridConfig = {
  loadingRenderer: (context) => {
    const el = document.createElement('div');
    el.className = 'my-spinner';
    // context.size is 'large' (grid-level) or 'small' (row/cell)
    el.style.width = context.size === 'large' ? '48px' : '16px';
    el.style.height = el.style.width;
    return el;
  },
};
```

#### React

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

const config: GridConfig = {
  loadingRenderer: ({ size }) => (
    <div
      className="my-spinner"
      style={{
        width: size === 'large' ? '48px' : '16px',
        height: size === 'large' ? '48px' : '16px',
      }}
    />
  ),
};

function MyGrid({ rows }) {
  return <DataGrid rows={rows} gridConfig={config} />;
}
```

#### Vue

```html
<script setup lang="ts">
import { h } from 'vue';
import { TbwGrid } from '@toolbox-web/grid-vue';
import type { GridConfig } from '@toolbox-web/grid-vue';

// Option 1: Render function
const config: GridConfig = {
  loadingRenderer: ({ size }) =>
    h('div', {
      class: 'my-spinner',
      style: {
        width: size === 'large' ? '48px' : '16px',
        height: size === 'large' ? '48px' : '16px',
      },
    }),
};

// Option 2: Vue component (receives `size` prop)
// import MySpinner from './MySpinner.vue';
// const config: GridConfig = { loadingRenderer: MySpinner };
</script>

<template>
  <TbwGrid :rows="rows" :grid-config="config" />
</template>
```

#### Angular

```typescript
import { Component, input } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { GridConfig } from '@toolbox-web/grid-angular';

// Loading spinner component (receives `size` input)
@Component({
  selector: 'app-spinner',
  template: `
    <div class="my-spinner"
      [style.width]="size() === 'large' ? '48px' : '16px'"
      [style.height]="size() === 'large' ? '48px' : '16px'">
    </div>
  `,
})
export class SpinnerComponent {
  size = input<'large' | 'small'>('large');
}

@Component({
  imports: [Grid],
  template: `<tbw-grid [rows]="rows" [gridConfig]="config" />`,
})
export class MyGridComponent {
  config: GridConfig = {
    loadingRenderer: SpinnerComponent,
  };
}
```

The `LoadingContext` provides a `size` property: `'large'` for the grid-wide overlay (up to 48×48 px) and `'small'` for row/cell indicators (sized to the row height).

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

  const grid = await queryGrid('#demo-custom-loading-renderer', true);
  const toggleBtn = document.querySelector<HTMLButtonElement>('[data-toggle="loading"]');

  if (grid) {
    grid.columns = [
      { field: 'id', header: 'ID', width: 80 },
      { field: 'name', header: 'Name' },
      { field: 'email', header: 'Email' },
      { field: 'department', header: 'Department' },
    ];

    grid.rows = [
      { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering' },
      { id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Marketing' },
      { id: 3, name: 'Carol Davis', email: 'carol@example.com', department: 'Engineering' },
      { id: 4, name: 'Dan Wilson', email: 'dan@example.com', department: 'Sales' },
      { id: 5, name: 'Eve Brown', email: 'eve@example.com', department: 'HR' },
    ];

    grid.registerStyles('custom-loading', `
      .progress-bar-container {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        height: 4px;
        background: light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.08));
        overflow: hidden;
        z-index: 1000;
      }
      .progress-bar {
        height: 100%;
        background: light-dark(#1976d2, #64b5f6);
        width: 30%;
        animation: progress-indeterminate 2s cubic-bezier(0.4,0,0.2,1) infinite;
        transform-origin: left;
      }
      @keyframes progress-indeterminate {
        0% { transform: translateX(-100%); }
        100% { transform: translateX(400%); }
      }
    `);

    grid.gridConfig = {
      getRowId: (row: { id: number }) => row.id,
      loadingRenderer: () => {
        const container = document.createElement('div');
        container.className = 'progress-bar-container';
        const bar = document.createElement('div');
        bar.className = 'progress-bar';
        container.appendChild(bar);
        return container;
      },
    };

    toggleBtn?.addEventListener('click', () => {
      grid.loading = !grid.loading;
    });
  }
```

### Empty State

When the grid has no rows and is **not** loading, it shows a built-in message (`No data to display`, or `No matching rows` when a filter plugin hides every source row). Override the message with `emptyRenderer` — typically to surface error text from a failed fetch, or to provide an actionable empty state.

```typescript
grid.gridConfig = {
  emptyRenderer: (ctx) => {
    if (loadError) return `Failed to load deals: ${loadError.message}`;
    if (ctx.filteredOut) return 'No deals match the current filter.';
    return 'No deals to display.';
  },
};
```

The renderer receives an [`EmptyContext`](/grid/api/core/interfaces/emptycontext.md) with:

- `sourceRowCount` — the number of input rows before any plugin filtering.
- `filteredOut` — `true` when `sourceRowCount > 0` but all rows were hidden (e.g. by the [FilteringPlugin](/grid/plugins/filtering.md) or grouping).

Return an `HTMLElement` for rich content (icons, buttons, multi-line layouts) or a `string` for plain text. Set `emptyRenderer: null` to suppress the overlay entirely.

The overlay mounts inside the rows container by default. Set `emptyOverlay: 'grid'` to cover the entire grid (including the header) instead — useful for full-page error states.

```typescript
grid.gridConfig = {
  emptyOverlay: 'grid',
  emptyRenderer: (ctx) => /* ... */,
};
```

:::note
The loading overlay always wins: while `grid.loading === true` the empty overlay is hidden, even if there are zero rows. This avoids flashing an empty message during the initial fetch.
:::

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

  type Row = { id: number; name: string; email: string; department: string };

  const allRows: Array<Row> = [
    { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering' },
    { id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Marketing' },
    { id: 3, name: 'Carol Davis', email: 'carol@example.com', department: 'Engineering' },
    { id: 4, name: 'Dan Wilson', email: 'dan@example.com', department: 'Sales' },
    { id: 5, name: 'Eve Brown', email: 'eve@example.com', department: 'HR' },
  ];

  const grid = await queryGrid('#demo-empty-state', true);
  const statusEl = document.querySelector<HTMLSpanElement>('[data-status]');

  if (grid) {
    grid.columns = [
      { field: 'id', header: 'ID', width: 80 },
      { field: 'name', header: 'Name' },
      { field: 'email', header: 'Email' },
      { field: 'department', header: 'Department' },
    ];

    let errorMessage: string | null = null;

    const setStatus = (msg: string) => {
      if (statusEl) statusEl.textContent = msg;
    };

    const emptyRenderer = (ctx: EmptyContext) => {
      if (errorMessage) {
        const wrapper = document.createElement('div');
        wrapper.className = 'empty-error';
        const icon = document.createElement('div');
        icon.className = 'empty-error-icon';
        icon.textContent = '⚠';
        const title = document.createElement('div');
        title.className = 'empty-error-title';
        title.textContent = 'Failed to load data';
        const detail = document.createElement('div');
        detail.className = 'empty-error-detail';
        detail.textContent = errorMessage;
        wrapper.append(icon, title, detail);
        return wrapper;
      }
      if (ctx.filteredOut) {
        return 'No employees match the current filter.';
      }
      return 'No employees to display. Click "Load data" to fetch.';
    };

    grid.registerStyles('empty-state-demo', `
      .empty-error {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 6px;
        text-align: center;
        max-width: 320px;
      }
      .empty-error-icon {
        font-size: 28px;
        color: light-dark(#c62828, #ef9a9a);
      }
      .empty-error-title {
        font-weight: 600;
        color: light-dark(#c62828, #ef9a9a);
      }
      .empty-error-detail {
        font-size: 0.85em;
        color: light-dark(rgba(0,0,0,0.6), rgba(255,255,255,0.6));
      }
    `);

    grid.gridConfig = {
      getRowId: (row: Row) => row.id,
      emptyRenderer,
    };

    grid.rows = [];
    setStatus('Empty state — showing default message.');

    document.querySelector('[data-action="load"]')?.addEventListener('click', () => {
      errorMessage = null;
      grid.rows = allRows;
      setStatus(`Loaded ${allRows.length} rows.`);
    });

    document.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
      errorMessage = null;
      grid.rows = [];
      setStatus('Cleared — showing empty message.');
    });

    document.querySelector('[data-action="error"]')?.addEventListener('click', () => {
      errorMessage = 'Network request timed out after 30s';
      grid.rows = [];
      setStatus('Simulated error — showing custom error renderer.');
    });
  }
```

---

## Variable Row Heights

By default all rows share a fixed height (`--tbw-row-height`, default 28 px). You can configure per-row heights using a function.

### Configuration

```typescript
// Fixed height for all rows
grid.gridConfig = { rowHeight: 56 };

// Per-row height function
grid.gridConfig = {
  rowHeight: (row, index) => (row.hasDetails ? 80 : 40),
};
```

If your function returns `undefined` for a row, the grid **auto-measures** that row's actual DOM height after rendering.

### How Auto-Measurement Works

1. The grid renders rows with an estimated height.
2. After paint, it reads `offsetHeight` for each rendered row.
3. Measured heights are cached and the position cache is rebuilt.
4. A `ResizeObserver` watches for late layout shifts (font loading, lazy images) and re-measures automatically.

### Row Identity for Height Caching

Measured heights are cached using:

- **`getRowId`** / `rowId` — preferred, survives sort/filter changes
- **Object reference** (WeakMap) — fallback when no ID is configured

Provide `getRowId` for best results when rows are re-sorted, filtered, or grouped.

### Plugin-Provided Heights

Plugins can override row heights by implementing the `getRowHeight(row, index)` hook. The **MasterDetailPlugin** and **ResponsivePlugin** use this to provide expanded-row heights.

### Performance Considerations

- **Fixed heights** are fastest — the grid can calculate positions with pure math.
- **Function-based heights** add a per-row function call during position-cache rebuilds.
- **Auto-measured heights** require a DOM read pass after rendering — avoid for 10 k+ row grids unless combined with `getRowId` caching.
- When mixing fixed and auto-measured rows, return a number for most rows and `undefined` only for rows that need measurement.

```ts
// VariableRowHeightDemo.astro
import '@toolbox-web/grid';
import { queryGrid, type CellRenderContext } from '@toolbox-web/grid';

  interface Employee {
    id: number;
    name: string;
    role: string;
    department: string;
    notes: string;
  }

  const container = document.getElementById('variable-row-height-demo');
  const grid = queryGrid<Employee>('tbw-grid', container!);
  if (!grid) throw new Error('Grid not found');

  grid.gridConfig = {
    columns: [
      { field: 'id', header: 'ID', width: 60 },
      { field: 'name', header: 'Name', width: 140 },
      { field: 'role', header: 'Role', width: 100 },
      { field: 'department', header: 'Department', width: 120 },
      { field: 'notes', header: 'Notes', renderer: (ctx: CellRenderContext<Employee>) => {
        const { row } = ctx;
        // For demonstration, render notes with a tooltip for tall rows
        const cell = document.createElement('div');
        cell.textContent = row.notes;
        if (tallRowIds.has(row.id)) {
          cell.title = 'This row has extended notes';
          cell.style.whiteSpace = 'pre-wrap';
          cell.style.fontStyle = 'italic';
        }
        return cell;
      } },
    ],
    getRowId: (row) => String(row.id),
    rowHeight: (row) => {
      // Rows with ids in tallRowIds get a taller height
      if (tallRowIds.has(row.id)) return 56;
      return undefined; // use default height
    },
  };

  // Generate 150 rows — a handful have long notes that warrant taller rows
  const tallRowIds = new Set([5, 18, 42, 77, 130]);
  const departments = ['Engineering', 'Marketing', 'Sales', 'Support', 'Finance', 'HR', 'Legal', 'Design'];
  const roles = ['Manager', 'Senior', 'Junior', 'Lead', 'Intern', 'Director', 'VP', 'Analyst'];

  grid.rows = Array.from({ length: 150 }, (_, i) => {
    const id = i + 1;
    const isTall = tallRowIds.has(id);
    return {
      id,
      name: `Employee ${id}`,
      role: roles[i % roles.length],
      department: departments[i % departments.length],
      notes: isTall
        ? `This employee has extended notes that require a taller row. ` +
          `Additional context: performance review pending, cross-team ` +
          `collaboration active, mentoring two junior engineers.`
        : `Standard note for employee ${id}.`,
    };
  });
```

---

## Events

The grid dispatches standard `CustomEvent`s for user interactions — clicks, sort changes, column resizes, and more. Plugin-specific events (selection, editing, filtering, etc.) are also available when those plugins are active.

For the complete event reference — including listening patterns per framework, cancelable events, and all plugin events — see the **[Events section](/grid/api-reference.md#events)** of the API Reference.

---

## Methods

The `<tbw-grid>` element exposes public methods for programmatic control — data manipulation, focus management, column state persistence, custom styles, shell control, loading indicators, row animation, and more. Use `createGrid()` or `queryGrid()` for type-safe access.

For the complete method reference — including signatures, return types, and factory functions — see the **[API Reference](/grid/api-reference.md#methods)** page.

---

## See Also

- [Getting Started](/grid/getting-started.md) — Installation and setup
- [Common Patterns](/grid/guides/common-patterns.md) — Full application recipes
- [Plugins](/grid/plugins.md) — Extend with selection, filtering, editing, and more
- [Theming](/grid/guides/theming.md) — CSS custom properties, dark mode, themes
- [Architecture](/grid/architecture.md) — Render scheduler, Light DOM, design decisions
- [Plugin System](/grid/plugin-development/custom-plugins.md) — Build your own plugins
