# @toolbox-web/grid — Full Documentation > A high-performance, framework-agnostic data grid built with pure TypeScript and native Web Components. Zero runtime dependencies. This file concatenates every prose documentation page — guides, plugins, and framework adapters — into one document for one-shot ingestion. Demo code is inlined as fenced blocks. The exhaustive per-symbol TypeDoc API is linked (not inlined) at the end to keep this file small; fetch any symbol by appending `.md` to its page URL. For the curated link index, see `llms.txt`. A few non-implementation pages (the changelogs and the AI-assistance overview) are intentionally omitted here to keep the corpus focused on building with the grid; they remain linked in `llms.txt`. --- # Introduction > A high-performance, framework-agnostic data grid web component for JavaScript, React, Angular, and Vue. Zero dependencies, 100k+ rows, virtualized rendering. ## RULE 0 (HIGHEST PRIORITY) — Prefer a single `gridConfig` over fragmented props When generating grid code for ANY framework (React, Angular, Vue, vanilla JS/TS, Svelte), **default to one `gridConfig` object** holding columns, features, plugins, and all other configuration. Reserve framework props for reactive data (`rows`) and the imperative `ref`/handle. Do NOT spread configuration across many individual props/inputs/attributes unless the user explicitly asks. WHY: one object describes the whole grid, is portable across frameworks (the same `GridConfig` works in vanilla, React, Angular, Vue), and keeps multi-line config (callbacks like `groupOn`, `dataSource`, renderers) readable. ```ts const gridConfig: GridConfig = { columns: [/* … */], features: { selection: 'range', editing: 'dblclick' }, }; ``` - **React:** `` (memoize with `useMemo`) - **Angular:** `` - **Vue:** `` (wrap with `markRaw()`) - **Vanilla:** `const grid = createGrid(gridConfig); grid.rows = data;` Individual feature props (``) remain fully supported — use them ONLY for very small grids (≤ 2 features, each a boolean/short string) or when explicitly requested. ## CRITICAL RULES — read before generating any code These apply to ALL frameworks. Violating them produces broken grids. 1. **Side-effect imports are always required.** `gridConfig.features.X` (or a feature prop) does NOT remove the need to import the feature factory — without the import the merge produces no plugin: ```ts import '@toolbox-web/grid/features/selection'; // vanilla / any framework import '@toolbox-web/grid-react/features/selection'; // or the adapter path ``` Vanilla JS must also register the element itself: `import '@toolbox-web/grid';` (adapters auto-import it). 2. **Height is required.** The grid needs an explicit height or it renders at zero height: `tbw-grid { height: 400px; }`. (`display: block` is set automatically — do not add it.) 3. **Editing is opt-in.** `editable: true` on a column WITHOUT the editing feature/plugin throws. Load `@toolbox-web/grid/features/editing` and set `features: { editing: true }`. 4. **Plugin load order matters.** `ClipboardPlugin` requires `SelectionPlugin`; `UndoRedoPlugin` requires `EditingPlugin` — load the dependency first. 5. **Some plugins are mutually exclusive.** `GroupingRows` ✗ `Tree` ✗ `Pivot` (all rewrite the row model); `ServerSide` ✗ `Pivot`. A dev-mode warning fires on conflict. (`ServerSide` + `Tree` and `ServerSide` + `GroupingRows` DO coexist.) 6. **Light DOM, no Shadow DOM.** CSS cascade works normally — no `::part()`/`::slotted()`. 7. **Em-based sizing.** All dimensions use `em`; scale the whole grid by changing `font-size` on `tbw-grid`. 8. **Type import:** `import type { DataGridElement } from '@toolbox-web/grid';` (the old `GridElement` alias is deprecated). ## ANTI-PATTERNS — don't reach for a plugin first - **Don't add `SelectionPlugin` just to make a row clickable.** For "click row → open detail", listen for `cell-activate` (fires for pointer AND keyboard, so it's accessible for free). Add `SelectionPlugin` only for persistent visible selection state, checkboxes, or multi-select. - **Don't write `cell-click`/`row-click` when you mean "activate".** Those are pointer-only — keyboard users won't trigger them. `cell-activate` is the unified, cancelable activation event. Use `cell-click` only when you specifically need pointer-only behaviour (e.g. left-vs-right button). - **Don't subclass `BaseGridEditor` before trying `column.type`.** Built-in types (`'select'`, `'number'`, `'date'`, …) plus `gridConfig.typeDefaults` cover most editor needs. Subclass only for genuinely custom editor UI. - **Don't reinvent post-render orchestration.** To act after first render (focus a cell, scroll to row, begin an edit), `await grid.ready()` instead of chaining `setTimeout`/`requestAnimationFrame`/`afterNextRender`. A **high-performance, framework-agnostic data grid** web component built with pure TypeScript. No runtime dependencies—just drop it into any project and start rendering data. ## Quick Start The grid ships as a standard custom element. Pick the install style that matches your setup. #### With a bundler (Vite, webpack, etc.) ```bash npm install @toolbox-web/grid ``` ```typescript title="main.ts" import '@toolbox-web/grid'; import { queryGrid } from '@toolbox-web/grid'; const grid = queryGrid('tbw-grid'); grid.columns = [ { field: 'id', header: 'ID', type: 'number', sortable: true }, { field: 'name', header: 'Name', sortable: true }, { field: 'email', header: 'Email' }, ]; 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' }, ]; ``` ```html title="index.html" ``` #### No build (CDN, single HTML file) Drop one ` ``` See the [Getting Started guide](/grid/getting-started.md) for framework integration (React, Vue, Angular), declarative HTML, plugins, and TypeScript setup. ### Live Demo ```ts // IntroBasicDemo.astro import '@toolbox-web/grid'; import { queryGrid } from '@toolbox-web/grid'; const grid = queryGrid('#demo-intro-basic'); if (grid) { grid.columns = [ { field: 'id', header: 'ID', type: 'number', sortable: true }, { field: 'name', header: 'Name', sortable: true }, { field: 'email', header: 'Email' }, ]; 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' }, { id: 4, name: 'Dan', email: 'dan@example.com' }, { id: 5, name: 'Eve', email: 'eve@example.com' }, ]; } ``` ## Architecture Highlights Under the hood, the grid employs patterns typically found in enterprise-grade solutions: | Pattern | What It Does | | ------- | ------------ | | **Centralized Render Scheduler** | Batches all updates into a single `requestAnimationFrame` per frame—no layout thrashing | | **Phase-Based Execution** | Prioritizes work (config → rows → columns → render) for predictable updates | | **DOM Recycling** | Reuses row elements via a pool with epoch-based invalidation—minimal GC pressure | | **Template Cloning** | Pre-created templates cloned via `cloneNode(true)`—3-4x faster than `createElement` | | **Event Delegation** | Single listener per event type on the container—scales to any dataset size | | **Faux Scrollbar** | Separates scroll container from content—no reflow during scroll | Read the full [Architecture deep-dive](/grid/architecture.md) for implementation details. ## Plugin System The core grid is intentionally minimal. Advanced features are delivered through tree-shakeable plugins — import only what you need: - **SelectionPlugin** — Cell, row, or range selection - **FilteringPlugin** — Column header filters with custom panels - **EditingPlugin** — Inline editing with built-in and custom editors - **GroupingRowsPlugin** — Hierarchical row grouping with aggregations - **TreePlugin** — Expandable tree data with lazy loading - **MasterDetailPlugin** — Expandable detail rows - **ExportPlugin** — CSV, Excel, or JSON export - **ClipboardPlugin** — Copy/paste with Excel-compatible formatting - ...and [many more](/grid/plugins.md) Plugins benefit from **dependency validation**, **type-safe config extension**, **auto-cleanup** via `AbortSignal`, and a **third-party friendly** API so you can [build and distribute your own](/grid/plugin-development/custom-plugins.md). ## Next Steps Ready to dive in? - [Getting Started](/grid/getting-started.md): Detailed setup for Vanilla JS, React, Vue, and Angular - [Core Features](/grid/core.md): Sorting, rendering, keyboard navigation, and interactive playground - [Plugins](/grid/plugins.md): Extend with selection, filtering, grouping, and 24+ more - [API Reference](/grid/api-reference.md): Complete property, method, and event reference --- --- # Getting Started > Install @toolbox-web/grid and render your first grid in under a minute. Covers npm, CDN, ES modules, declarative HTML, and full framework integration for Vanilla JS, React, Vue, and Angular. **Using a framework?** Jump directly to [Angular](/grid/angular/getting-started.md), [React](/grid/react/getting-started.md), or [Vue](/grid/vue/getting-started.md). ## Quick Start 1. **Install the package** #### npm ```bash npm install @toolbox-web/grid ``` #### yarn ```bash yarn add @toolbox-web/grid ``` #### pnpm ```bash pnpm add @toolbox-web/grid ``` #### bun ```bash bun add @toolbox-web/grid ``` #### CDN For quick prototyping, use the UMD bundle directly: ```html ``` 2. **Import and use** ```typescript import '@toolbox-web/grid'; // registers import { queryGrid } from '@toolbox-web/grid'; // typed DOM helper ``` 3. **Add the grid to your HTML** ```html ``` :::caution[The grid needs a height] Set a height via CSS or inline style (e.g., `height: 400px`). Without it, the grid collapses to zero height and nothing will render. Use `height: auto` if you don't want virtual scrolling. **Tip:** A parent CSS Grid or Flexbox layout that stretches the grid (e.g., a flex child with `flex: 1` or a grid row sized with `1fr`) also counts as a height — you don't need to set one explicitly on `` itself. ::: ### Declarative Columns (No JavaScript) Define columns directly in HTML — great for static layouts and quick prototyping: ```html ``` Set `rows` from JavaScript or via the `rows` HTML attribute (JSON). See [Light DOM Columns](/grid/core.md#light-dom-columns) for the full attribute reference. ### Auto-Inferred Columns Skip column configuration entirely — the grid creates columns from your data: ```typescript import '@toolbox-web/grid'; import { queryGrid } from '@toolbox-web/grid'; const grid = queryGrid('#my-grid'); grid.rows = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com', active: true }, { id: 2, name: 'Bob Smith', email: 'bob@example.com', active: false }, ]; // → Creates ID, Name, Email, and Active columns automatically ``` The grid detects types (`number`, `boolean`, `date`, `string`) from values and formats headers from field names (`firstName` → `First Name`). See [Column Inference](/grid/core.md#column-inference) for details. ## Framework Integration The grid is a standard web component that works in any JavaScript environment — you can always use the Vanilla JS approach in any framework. For React, Vue, and Angular, we also provide dedicated adapter packages that enable custom component renderers and editors. :::tip[Prefer a single `gridConfig` object] Across every framework, the recommended pattern is to describe the grid with one `gridConfig` object — columns, `features`, and `plugins` together — and pass it as a single prop/input/attribute. Keep reactive data (`rows`) separate, since it changes independently of configuration. A single config object is portable (the same `GridConfig` works in vanilla, React, Angular, and Vue), easy to share or snapshot in tests, and keeps multi-line feature config readable. Individual feature props (e.g. `selection="row"`) remain fully supported and are a fine shorthand for very small grids — but default to `gridConfig` for everything else, including any column that uses a custom renderer or editor. ::: #### TypeScript Add a grid element to your HTML, then configure it from JavaScript: ```html title="index.html" ``` ```typescript title="main.ts" import '@toolbox-web/grid'; import { queryGrid } from '@toolbox-web/grid'; const grid = queryGrid('tbw-grid'); grid.columns = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, ]; grid.rows = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com' }, { id: 3, name: 'Carol White', email: 'carol@example.com' }, ]; ``` That's a working grid. From here, add features as you need them — sorting, editing, selection, and more are each a one-line import: ```typescript title="main.ts (with features)" import '@toolbox-web/grid'; import { queryGrid } from '@toolbox-web/grid'; import '@toolbox-web/grid/features/editing'; import '@toolbox-web/grid/features/selection'; import '@toolbox-web/grid/features/filtering'; const grid = queryGrid('tbw-grid'); grid.gridConfig = { columns: [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name', editable: true }, { field: 'email', header: 'Email', editable: true }, ], features: { editing: 'dblclick', selection: 'row', filtering: true, }, }; grid.rows = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com' }, { id: 3, name: 'Carol White', email: 'carol@example.com' }, ]; grid.on('cell-commit', (detail) => console.log('Edited:', detail)); ``` See [Core Features](/grid/core.md) for renderers, formatters, and custom editors. :::note[Prefer creating grids programmatically?] Use `createGrid()` to create a `` element without writing HTML: ```typescript import { createGrid } from '@toolbox-web/grid'; const grid = createGrid({ columns: [...], features: { ... } }); document.body.appendChild(grid); ``` ::: #### React For React projects, use the `@toolbox-web/grid-react` adapter package for enhanced integration: ```bash # Install both packages npm install @toolbox-web/grid @toolbox-web/grid-react ``` ```tsx title="EmployeeGrid.tsx" import { DataGrid } from '@toolbox-web/grid-react'; const employees = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com' }, { id: 3, name: 'Carol White', email: 'carol@example.com' }, ]; function EmployeeGrid() { return ( ); } ``` That's a working grid. From here, add features as you need them — each is a one-line import plus a prop: ```tsx title="EmployeeGrid.tsx (with features)" import '@toolbox-web/grid-react/features/editing'; import '@toolbox-web/grid-react/features/selection'; import '@toolbox-web/grid-react/features/filtering'; import { DataGrid } from '@toolbox-web/grid-react'; const employees = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com' }, { id: 3, name: 'Carol White', email: 'carol@example.com' }, ]; function EmployeeGrid() { return ( console.log('Edited:', e.detail)} style={{ height: 400, display: 'block' }} /> ); } ``` The adapter adds JSX renderers/editors, the `useGrid` hook, and declarative `GridColumn` components. See the [React adapter docs](/grid/react/getting-started.md) for custom renderers, editors, and the complete API reference. #### Vue For Vue projects, use the `@toolbox-web/grid-vue` adapter package for enhanced integration: ```bash # Install both packages npm install @toolbox-web/grid @toolbox-web/grid-vue ``` ```html title="EmployeeGrid.vue" ``` That's a working grid. From here, add features as you need them — each is a one-line import plus a prop: ```html title="EmployeeGrid.vue (with features)" ``` The adapter adds slot-based renderers/editors (`#cell`, `#editor`), the `useGrid` composable, and declarative `TbwGridColumn` components. See the [Vue adapter docs](/grid/vue/getting-started.md) for custom renderers, editors, and the complete API reference. #### Angular For Angular projects, use the `@toolbox-web/grid-angular` adapter package for enhanced integration: ```bash # Install both packages npm install @toolbox-web/grid @toolbox-web/grid-angular ``` ```typescript title="grid.component.ts" import '@toolbox-web/grid'; import { Component } from '@angular/core'; import { Grid } from '@toolbox-web/grid-angular'; import type { ColumnConfig } from '@toolbox-web/grid'; @Component({ selector: 'app-employee-grid', imports: [Grid], template: ` `, }) export class EmployeeGridComponent { employees = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com' }, { id: 3, name: 'Carol White', email: 'carol@example.com' }, ]; columns: ColumnConfig[] = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, ]; } ``` That's a working grid. From here, add features as you need them — each is a one-line import plus an input binding: ```typescript title="grid.component.ts (with features)" import { GridEditingDirective } from '@toolbox-web/grid-angular/features/editing'; import { GridSelectionDirective } from '@toolbox-web/grid-angular/features/selection'; import { GridFilteringDirective } from '@toolbox-web/grid-angular/features/filtering'; import '@toolbox-web/grid'; import { Component } from '@angular/core'; import { Grid } from '@toolbox-web/grid-angular'; import type { ColumnConfig, CellCommitDetail } from '@toolbox-web/grid'; @Component({ selector: 'app-employee-grid', imports: [Grid, GridEditingDirective, GridSelectionDirective, GridFilteringDirective], template: ` `, }) export class EmployeeGridComponent { employees = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com' }, { id: 3, name: 'Carol White', email: 'carol@example.com' }, ]; columns: ColumnConfig[] = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name', editable: true }, { field: 'email', header: 'Email', editable: true }, ]; // camelCase outputs deliver the unwrapped detail directly ($event is the // detail, not the native CustomEvent). Bind kebab-case (cell-commit) instead // if you need the CustomEvent for event.preventDefault(). onCellCommit(detail: CellCommitDetail) { console.log('Edited:', detail); } } ``` The adapter adds structural directives (`*tbwRenderer`, `*tbwEditor`), template-driven renderers/editors, and grid-level event outputs. See the [Angular adapter docs](/grid/angular/getting-started.md) for custom renderers, editors, and the complete API reference. ## Plain JavaScript (No Build Step) Don't use TypeScript or a bundler? The grid works with a single ` ``` To add plugins, use the all-in-one bundle and configure via `gridConfig`: ```html title="index.html (with plugins)" ``` :::tip `grid.umd.js` includes core only (~168 kB raw, ~48 kB gzipped). `grid.all.umd.js` bundles core + all plugins (~514 kB raw, ~136 kB gzipped). You can also load individual plugin UMD files (e.g. `selection.umd.js`) for finer control over bundle size. ::: ## TypeScript Support The package ships with full type definitions. Use generics on `queryGrid` to get typed row data throughout your code: ```typescript import '@toolbox-web/grid'; import { queryGrid } from '@toolbox-web/grid'; import type { ColumnConfig } from '@toolbox-web/grid'; interface Employee { id: number; name: string; email: string; } const grid = queryGrid('tbw-grid'); // Column config is type-checked against Employee const columns: ColumnConfig[] = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name' }, // { field: 'typo' } ← TypeScript error! ]; // Event payloads are typed too grid.on('cell-commit', ({ row, field, value }) => { console.log(row.name, field, value); // row is Employee }); ``` ## Next Steps Now that you have the grid set up, explore: - [Core Features](/grid/core.md): Sorting, editing, keyboard navigation, and interactive playground - [Selection Plugin](/grid/plugins/selection.md): Add row, cell, or range selection - [Theming](/grid/guides/theming.md): Customize colors, spacing, and typography - [API Reference](/grid/api-reference.md): Complete property, method, and event reference --- # 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 = { 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[] = []; 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 | |-----|--------| | | Move between cells | | Home / End | Jump to first/last cell in row | | Ctrl + Home / Ctrl + End | Jump to first/last cell in grid | | PgUp / PgDn | Scroll by viewport height | | ↵ Enter | Start editing (with EditingPlugin) | | Esc | Cancel editing | | ⇥ Tab / ⇧ Shift + ⇥ Tab | 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 ``` ```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 `` elements (or framework wrapper components) to declaratively define columns directly in your markup: #### TypeScript ```html ``` #### React ```tsx import { DataGrid, GridColumn } from '@toolbox-web/grid-react'; function EmployeeGrid({ rows }) { return ( ); } ``` #### Vue ```html ``` #### Angular ```html ``` 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 ``` The columns will appear in order: `name`, `email`, `id`. #### React ```tsx ``` #### Vue ```html ``` #### Angular ```html ``` 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 `` 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` | Default format/renderer per column `type` | | `plugins` | [`GridPlugin[]`](/grid/api/plugin-development/interfaces/gridplugin.md) | Plugin instances | | `features` | [`Partial`](/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('#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 ( ( {value} )} /> ( )} /> ); } ``` #### Vue ```html ``` #### Angular ```typescript import { Component } from '@angular/core'; import { Grid, TbwRenderer } from '@toolbox-web/grid-angular'; @Component({ imports: [Grid, TbwRenderer], template: ` {{ value }} `, }) 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('#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 = { 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 `` element inside a ``; its content becomes the cell template. Use `{{ value }}` to interpolate the cell value (and `{{ row.field }}` for other row fields): ```html {{ value }} ``` The companion light-DOM templates are `` (custom editor, requires the [Editing plugin](/grid/plugins/editing.md)) and `` (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 ``` #### 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: ` `, }) 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} *`; return span; }, } ``` #### React ```tsx // In GridConfig or ColumnConfig { field: 'name', header: 'Name', headerLabelRenderer: ({ value }) => ( {value} * ), } ``` #### 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() }} *`, }) export class RequiredLabelComponent { value = input(); column = input(); } ``` **`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) => (
📧 {ctx.value} {ctx.sortState && ( {ctx.sortState === 'asc' ? '▲' : '▼'} )}
), } ``` #### 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: `
📧 {{ value() }}
`, }) export class EmailHeaderComponent { value = input(); column = input(); sortState = input<'asc' | 'desc' | null>(); filterActive = input(); 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} *`; 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 { localStorage.setItem('my-grid-state', JSON.stringify(e.detail)); }} /> ``` #### Vue ```html ``` #### Angular ```html ``` ```typescript onColumnStateChange(e: CustomEvent) { 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('[data-status]')!; const log = container.querySelector('[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: ``. **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('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 = { grid: 'Click ▶ Simulate to show full-grid loading overlay.', row: 'Click any row to trigger row loading.', cell: 'Click any cell 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 }) => (
), }; function MyGrid({ rows }) { return ; } ``` #### Vue ```html ``` #### 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: `
`, }) export class SpinnerComponent { size = input<'large' | 'small'>('large'); } @Component({ imports: [Grid], template: ``, }) 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('[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 = [ { 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('[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('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) => { 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 `` 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 --- # Demos > Full-featured demo applications showcasing @toolbox-web/grid with 15+ plugins, custom editors, master-detail, and more. ## Reference Implementation — Employee Management (advanced, end-to-end) This is the canonical advanced grid in this project: ~15 columns mixing text, number, date, select and boolean; custom renderers and editors; master-detail; multi-sort; per-column filtering; row grouping; CSV/Excel export; clipboard; context menu; undo/redo; pinned rows; and side tool panels. In the cross-framework `llms-full.txt` (and this page's `.md`) the source below is the **vanilla, framework-agnostic implementation** — the `GridConfig` object is portable verbatim to the React, Angular and Vue adapters (per RULE 0 in the [Introduction](/grid/introduction.md): one `gridConfig` over fragmented props). In a per-framework `llms-full-{react,vue,angular}.txt` corpus the same reference is shown as that framework's **native implementation** (adapter components plus JSX / SFC / component renderers and editors), drawn from `demos/{angular,react,vue}/src/demos/employee-management/`. Only the row type and seed data (`demos/shared/employee-management/`) are shared across all of them. Read the files in this order: the domain model, then the grid config (columns + `features` + manual `plugins`), then the custom renderers, editors and tool panels, then the factory or component that wires them together. ```ts // demos/shared/employee-management/types.ts /** * Shared Data Models for Employee Management Demo * * These type definitions are shared across all framework implementations * (Vanilla, React, Angular, Vue) of the Employee Management demo. */ // Re-export grid element type for easier imports in demos export type { TbwGrid as GridElement } from '@toolbox-web/grid'; /** * Represents a project that an employee is working on or has completed. */ export interface Project { id: string; name: string; role: string; hoursLogged: number; status: 'active' | 'completed' | 'on-hold'; } /** * Represents a quarterly performance review for an employee. */ export interface PerformanceReview { year: number; quarter: string; score: number; notes: string; } /** * Represents an employee record in the HR management system. */ export interface Employee { id: number; firstName: string; lastName: string; email: string; department: string; team: string; title: string; level: 'Junior' | 'Mid' | 'Senior' | 'Lead' | 'Principal' | 'Director'; salary: number; bonus: number; status: 'Active' | 'On Leave' | 'Remote' | 'Contract' | 'Terminated'; hireDate: string; lastPromotion: string | null; manager: string | null; location: string; timezone: string; skills: string[]; rating: number; completedProjects: number; activeProjects: Project[]; performanceReviews: PerformanceReview[]; isTopPerformer: boolean; } /** * Configuration options for the demo (used by story controls). */ export interface DemoConfig { rowCount: number; enableSelection: boolean; enableFiltering: boolean; enableSorting: boolean; enableEditing: boolean; enableMasterDetail: boolean; enableRowGrouping: boolean; } ``` ```ts // demos/vanilla/src/demos/employee-management/grid-config.ts /** * Grid Configuration for Employee Management Demo * * This file demonstrates the `features` configuration API for @toolbox-web/grid. * Most plugins are configured declaratively via `features: { ... }` on the grid config. * The PinnedRowsPlugin is configured manually via `plugins: [...]` to show both approaches. */ // Feature side-effect imports — register feature factories in the core registry. // Each import is tiny (~200-300 bytes) and only includes the factory + type augmentation. import '@toolbox-web/grid/features/clipboard'; import '@toolbox-web/grid/features/column-virtualization'; import '@toolbox-web/grid/features/context-menu'; import '@toolbox-web/grid/features/editing'; import '@toolbox-web/grid/features/export'; import '@toolbox-web/grid/features/filtering'; import '@toolbox-web/grid/features/grouping-columns'; import '@toolbox-web/grid/features/grouping-rows'; import '@toolbox-web/grid/features/master-detail'; import '@toolbox-web/grid/features/multi-sort'; import '@toolbox-web/grid/features/pinned-columns'; import '@toolbox-web/grid/features/pinned-rows'; import '@toolbox-web/grid/features/reorder-columns'; import '@toolbox-web/grid/features/responsive'; import '@toolbox-web/grid/features/selection'; import '@toolbox-web/grid/features/shell'; import '@toolbox-web/grid/features/undo-redo'; import '@toolbox-web/grid/features/visibility'; // PinnedRowsPlugin is imported directly to demonstrate the manual plugins approach. // Built-in panel renderers are imported alongside it for the new slots[] API. import type { GridConfig } from '@toolbox-web/grid'; import { filteredCountPanel, PinnedRowsPlugin, rowCountPanel } from '@toolbox-web/grid/plugins/pinned-rows'; import { DEPARTMENTS, type Employee } from '@demo/shared/employee-management'; import { bonusSliderEditor, dateEditor, starRatingEditor, statusSelectEditor } from './editors'; import { createDetailRenderer, createResponsiveCardRenderer, ratingRenderer, statusViewRenderer, topPerformerRenderer, } from './renderers'; // ============================================================================= // COLUMN GROUPS // ============================================================================= /** * Column groups for the employee grid. * Used by GroupingColumnsPlugin to create grouped headers. * Also used by column-move handler to enforce group constraints. */ export const COLUMN_GROUPS = [ { id: 'employee', header: 'Employee Info', children: ['firstName', 'lastName', 'email'] }, { id: 'organization', header: 'Organization', children: ['department', 'team', 'title', 'level'] }, { id: 'compensation', header: 'Compensation', children: ['salary', 'bonus'] }, { id: 'status', header: 'Status & Performance', children: ['status', 'hireDate', 'rating', 'isTopPerformer', 'location'], }, ]; // ============================================================================= // CONFIGURATION OPTIONS // ============================================================================= /** * Options for configuring the grid. * Toggle features on/off based on demo requirements. */ export interface GridConfigOptions { enableSelection: boolean; enableFiltering: boolean; enableSorting: boolean; enableEditing: boolean; enableMasterDetail: boolean; enableRowGrouping?: boolean; } // ============================================================================= // GRID CONFIGURATION FACTORY // ============================================================================= /** * Creates a complete grid configuration for the employee management demo. * * This configuration includes: * - 15 columns with various types (text, number, date, select, boolean) * - Custom editors (star rating, bonus slider, status select, date picker) * - Custom renderers (status badges, rating colors, top performer badge) * - Shell header with title * - Multiple plugins for advanced features * * @example * ```ts * const config = createGridConfig({ * enableSelection: true, * enableFiltering: true, * enableSorting: true, * enableEditing: true, * enableMasterDetail: true, * }); * * grid.gridConfig = config; * ``` */ export function createGridConfig(options: GridConfigOptions): GridConfig { const { enableSelection, enableFiltering, enableSorting, enableEditing, enableMasterDetail, enableRowGrouping = false, } = options; return { fitMode: 'fixed', // Column groups for grouped headers columnGroups: COLUMN_GROUPS, // Column definitions columns: [ { field: 'id', header: 'ID', type: 'number', width: 70, sortable: true }, { field: 'firstName', header: 'First Name', minWidth: 100, editable: enableEditing, sortable: true, resizable: true, }, { field: 'lastName', header: 'Last Name', minWidth: 100, editable: enableEditing, sortable: true, resizable: true, }, { field: 'email', header: 'Email', minWidth: 200, resizable: true }, { field: 'department', header: 'Dept', width: 120, sortable: true, editable: enableEditing, type: 'select', options: DEPARTMENTS.map((d) => ({ label: d, value: d })), }, { field: 'team', header: 'Team', width: 110, sortable: true }, { field: 'title', header: 'Title', minWidth: 160, editable: enableEditing, resizable: true }, { field: 'level', header: 'Level', width: 90, sortable: true, editable: enableEditing, type: 'select', options: ['Junior', 'Mid', 'Senior', 'Lead', 'Principal', 'Director'].map((l) => ({ label: l, value: l })), }, { field: 'salary', header: 'Salary', type: 'number', width: 110, editable: enableEditing, sortable: true, resizable: true, format: (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }), }, { field: 'bonus', header: 'Bonus', type: 'number', width: 180, sortable: true, editable: enableEditing, editor: bonusSliderEditor, format: (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }), }, { field: 'status', header: 'Status', width: 140, sortable: true, editable: enableEditing, editor: statusSelectEditor, renderer: statusViewRenderer, }, { field: 'hireDate', header: 'Hire Date', type: 'date', width: 130, sortable: true, editable: enableEditing, editor: dateEditor, }, { field: 'rating', header: 'Rating', type: 'number', width: 120, sortable: true, editable: enableEditing, editor: starRatingEditor, renderer: ratingRenderer, }, { field: 'isTopPerformer', header: '⭐', type: 'boolean', width: 50, sortable: false, renderer: topPerformerRenderer, }, { field: 'location', header: 'Location', width: 110, sortable: true }, ], // Grid-wide feature toggles (used by plugins that support enable/disable) sortable: enableSorting, filterable: enableFiltering, selectable: enableSelection, // Declarative feature configuration — the recommended approach. // Each key corresponds to a feature side-effect import above. // The grid creates plugin instances from these configs automatically. features: { // Shell (header, tool panels) — best-practice feature opt-in. shell: { header: { title: 'Employee Management System (JS)', }, toolPanel: { position: 'right' as const, width: 300 }, }, selection: 'range', multiSort: true, filtering: { debounceMs: 200 }, // EditingPlugin always loaded; toggle via editOn to avoid validation errors // when columns have `editable: true` editing: enableEditing ? 'dblclick' : { editOn: false }, clipboard: true, contextMenu: true, reorderColumns: true, groupingColumns: true, pinnedColumns: true, columnVirtualization: true, visibility: true, // Responsive plugin for mobile/narrow layouts responsive: { breakpoint: 700, cardRenderer: (row: Employee) => createResponsiveCardRenderer(row), cardRowHeight: 80, hiddenColumns: ['id', 'email', 'team', 'level', 'bonus', 'hireDate', 'isTopPerformer', 'location'], }, // Row grouping (works alongside master-detail) ...(enableRowGrouping ? { groupingRows: { groupOn: (row: unknown) => (row as Employee).department, defaultExpanded: false, showRowCount: true, aggregators: { salary: 'sum', rating: (rows: Record[], field: string) => { const sum = rows.reduce((acc, row) => acc + (Number(row[field]) || 0), 0); return rows.length ? (sum / rows.length).toFixed(1) : ''; }, }, }, } : {}), // Master-detail (works alongside row grouping — details appear on data rows within groups) ...(enableMasterDetail ? { masterDetail: { detailRenderer: (row: unknown) => createDetailRenderer(row as Employee), showExpandColumn: true, animation: 'slide' as const, }, } : {}), undoRedo: { maxHistorySize: 100 }, export: true, }, // Manual plugins array — PinnedRowsPlugin kept here to demonstrate // that `features` and `plugins` can be mixed in the same config. plugins: [ new PinnedRowsPlugin({ slots: [ { id: 'totals', position: 'bottom', label: 'Summary:', cells: { salary: (rows: unknown[]) => (rows as Employee[]) .reduce((acc, r) => acc + (r.salary || 0), 0) .toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }), bonus: (rows: unknown[]) => (rows as Employee[]) .reduce((acc, r) => acc + (r.bonus || 0), 0) .toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }), rating: (rows: unknown[]) => { const vals = (rows as Employee[]).map((r) => r.rating).filter(Boolean); return vals.length ? `Avg: ${(vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(2)}` : ''; }, }, }, { id: 'count', position: 'bottom', render: rowCountPanel() }, { id: 'filtered', position: 'bottom', render: filteredCountPanel() }, ], }), ], }; } ``` ```ts // demos/vanilla/src/demos/employee-management/renderers.ts /** * View Renderers for the Vanilla Employee Management Demo * * View renderers customize how cell values are displayed in read mode. */ import type { Employee } from '@demo/shared/employee-management'; /** * Renders a status badge with color-coded styling. */ export const statusViewRenderer = ({ value }: { value: string }): string => { const statusClass = value.toLowerCase().replace(/\s+/g, '-'); return `${value}`; }; /** * Renders a star indicator for top performer status. */ export const topPerformerRenderer = ({ value }: { value: boolean }): string => { return value ? '' : ''; }; /** * Renders a rating value with color coding based on score. */ export const ratingRenderer = ({ value }: { value: number }): string => { const level = value >= 4.5 ? 'high' : value >= 3.5 ? 'medium' : 'low'; return `${value.toFixed(1)} ★`; }; /** * Creates a detail panel element for master-detail view. * Shows employee's active projects, performance reviews, and skills. */ export const createDetailRenderer = (employee: Employee): HTMLElement => { const container = document.createElement('div'); container.className = 'detail-panel'; const projectsHtml = employee.activeProjects .map( (p) => `` + `${p.id}` + `${p.name}` + `${p.role}` + `${p.hoursLogged}h` + `` + `${p.status}` + ``, ) .join(''); const reviewsHtml = employee.performanceReviews .slice(-4) .map((r) => { const scoreLevel = r.score >= 4 ? 'high' : r.score >= 3 ? 'medium' : 'low'; return ( `
` + `
${r.quarter} ${r.year}
` + `
${r.score.toFixed(1)}
` + `
${r.notes}
` + `
` ); }) .join(''); const skillsHtml = employee.skills.map((s) => `${s}`).join(''); container.innerHTML = `
` + `
` + `

Active Projects

` + `` + `` + `` + `` + `` + `` + `` + `` + `${projectsHtml}` + `
IDProjectRoleHoursStatus
` + `
` + `
` + `

Performance Reviews

` + `
${reviewsHtml}
` + `
` + `

Skills

` + `${skillsHtml}` + `
` + `
` + `
`; return container; }; /** * Creates a responsive card element for mobile/narrow layouts. * Shows compact employee info with avatar placeholder, status badge, and key metrics. */ export const createResponsiveCardRenderer = (employee: Employee): HTMLElement => { const card = document.createElement('div'); card.className = 'responsive-employee-card'; // Get initials for avatar placeholder const initials = `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`; // Determine department color const deptColors: Record = { Engineering: '#3b82f6', Marketing: '#ec4899', Sales: '#f59e0b', HR: '#10b981', Finance: '#6366f1', Legal: '#8b5cf6', Operations: '#14b8a6', 'Customer Support': '#f97316', }; const deptColor = deptColors[employee.department] ?? '#6b7280'; // Format salary const salary = employee.salary.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }); // Status class const statusClass = employee.status.toLowerCase().replace(/\s+/g, '-'); card.innerHTML = `
` + `${initials}` + `
` + `
` + `
` + `${employee.firstName} ${employee.lastName}` + `${employee.status}` + `
` + `
${employee.title}
` + `
` + `${employee.department}` + `` + `${salary}` + `` + `${employee.rating.toFixed(1)} ★` + `${employee.isTopPerformer ? '' : ''}` + `
` + `
`; return card; }; ``` ```ts // demos/vanilla/src/demos/employee-management/editors.ts /** * Custom Cell Editors for the Vanilla Employee Management Demo * * Each editor demonstrates different interaction patterns for inline editing. */ import type { Employee } from '@demo/shared/employee-management'; import type { ColumnEditorContext } from '@toolbox-web/grid'; /** * Interactive 5-star rating editor with keyboard support. */ export const starRatingEditor = (ctx: ColumnEditorContext): HTMLElement => { const container = document.createElement('div'); container.className = 'star-rating-editor'; container.setAttribute('tabindex', '0'); let currentValue = ctx.value ?? 3; const renderStars = () => { container.innerHTML = ''; for (let i = 1; i <= 5; i++) { const star = document.createElement('span'); const filled = i <= Math.round(currentValue); star.textContent = filled ? '★' : '☆'; star.className = `star-rating-editor__star ${filled ? 'star-rating-editor__star--filled' : 'star-rating-editor__star--empty'}`; star.dataset.value = String(i); star.addEventListener('click', (e) => { e.stopPropagation(); currentValue = i; renderStars(); ctx.commit(i); }); container.appendChild(star); } const label = document.createElement('span'); label.textContent = ` ${currentValue.toFixed(1)}`; label.className = 'star-rating-editor__label'; container.appendChild(label); }; container.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft' && currentValue > 1) { currentValue = Math.max(1, currentValue - 0.5); renderStars(); } else if (e.key === 'ArrowRight' && currentValue < 5) { currentValue = Math.min(5, currentValue + 0.5); renderStars(); } else if (e.key === 'Enter') { ctx.commit(currentValue); } else if (e.key === 'Escape') { ctx.cancel(); } }); renderStars(); // Note: Don't auto-focus here - the grid handles focus via beginBulkEdit/inlineEnterEdit // Auto-focusing here would cause all editors to fight for focus, with the last one winning return container; }; /** * Bonus slider editor with percentage display. */ export const bonusSliderEditor = (ctx: ColumnEditorContext): HTMLElement => { const container = document.createElement('div'); container.className = 'bonus-slider-editor'; const salary = ctx.row.salary || 100000; const minBonus = Math.round(salary * 0.02); const maxBonus = Math.round(salary * 0.25); let currentValue = ctx.value ?? Math.round(salary * 0.1); const slider = document.createElement('input'); slider.type = 'range'; slider.min = String(minBonus); slider.max = String(maxBonus); slider.value = String(currentValue); slider.className = 'bonus-slider-editor__slider'; const display = document.createElement('span'); const updateDisplay = () => { const percent = ((currentValue / salary) * 100).toFixed(1); const colorClass = parseFloat(percent) >= 15 ? 'bonus-slider-editor__value--high' : parseFloat(percent) >= 10 ? 'bonus-slider-editor__value--medium' : 'bonus-slider-editor__value--low'; display.innerHTML = `$${currentValue.toLocaleString()} (${percent}%)`; }; display.className = 'bonus-slider-editor__display'; updateDisplay(); slider.addEventListener('input', () => { currentValue = parseInt(slider.value, 10); updateDisplay(); }); slider.addEventListener('change', () => ctx.commit(currentValue)); slider.addEventListener('keydown', (e) => { if (e.key === 'Enter') ctx.commit(currentValue); else if (e.key === 'Escape') ctx.cancel(); }); container.appendChild(slider); container.appendChild(display); // Note: Don't auto-focus here - the grid handles focus via beginBulkEdit/inlineEnterEdit return container; }; /** * Status selection editor with colored badges. */ export const statusSelectEditor = (ctx: ColumnEditorContext): HTMLElement => { const container = document.createElement('div'); container.className = 'status-select-editor'; const statusConfig: Record = { Active: { bg: '#d4edda', text: '#155724', icon: '✓' }, Remote: { bg: '#cce5ff', text: '#004085', icon: '🏠' }, 'On Leave': { bg: '#fff3cd', text: '#856404', icon: '🌴' }, Contract: { bg: '#e2e3e5', text: '#383d41', icon: '📄' }, Terminated: { bg: '#f8d7da', text: '#721c24', icon: '✗' }, }; const select = document.createElement('select'); select.className = 'status-select-editor__select'; Object.entries(statusConfig).forEach(([status, config]) => { const option = document.createElement('option'); option.value = status; option.textContent = `${config.icon} ${status}`; option.selected = status === ctx.value; select.appendChild(option); }); container.appendChild(select); select.addEventListener('change', () => ctx.commit(select.value)); select.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); ctx.cancel(); } }); // Note: Don't auto-focus here - the grid handles focus via beginBulkEdit/inlineEnterEdit return container; }; /** * Native HTML5 date input editor. */ export const dateEditor = (ctx: ColumnEditorContext): HTMLElement => { const input = document.createElement('input'); input.type = 'date'; input.value = ctx.value || ''; input.className = 'date-editor'; input.addEventListener('change', () => ctx.commit(input.value)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') ctx.commit(input.value); else if (e.key === 'Escape') ctx.cancel(); }); // Note: Don't auto-focus here - the grid handles focus via beginBulkEdit/inlineEnterEdit return input; }; ``` ```ts // demos/vanilla/src/demos/employee-management/tool-panels.ts /** * Tool Panels for the Vanilla Employee Management Demo * * Tool panels are registered with the grid's shell and appear in a sidebar. * This demonstrates how clean the API is - no type casting needed! */ import { DEPARTMENTS, type Employee, type GridElement } from '@demo/shared/employee-management'; import { shadowDomStyles } from '@demo/shared/employee-management/styles'; import { FilteringPlugin } from '@toolbox-web/grid/all'; /** * Injects all demo styles into the grid. * Uses the grid's registerStyles() API. */ export function injectToolPanelStyles(grid: GridElement): void { grid.registerStyles?.('demo-styles', shadowDomStyles); } /** * Registers the Quick Filters tool panel. */ export function registerQuickFiltersPanel(grid: GridElement): void { grid.getPluginByName('shell')?.registerToolPanel({ id: 'quick-filters', title: 'Quick Filters', icon: '🔍', tooltip: 'Apply quick filters to the data', order: 10, render: (container: HTMLElement) => { const content = document.createElement('div'); content.className = 'tool-panel-content'; content.innerHTML = `
${['Junior', 'Mid', 'Senior', 'Lead', 'Principal', 'Director'] .map( (l) => ` `, ) .join('')}
${['Active', 'Remote', 'On Leave', 'Contract', 'Terminated'] .map( (s) => ` `, ) .join('')}
≥ 0
`; container.appendChild(content); // Pill toggle styling content.querySelectorAll('.level-filter, .status-filter').forEach((input) => { input.addEventListener('change', (e) => { const cb = e.target as HTMLInputElement; cb.closest('.filter-pill')?.classList.toggle('filter-pill--active', cb.checked); }); }); // Rating slider const ratingSlider = content.querySelector('#rating-filter') as HTMLInputElement; const ratingValue = content.querySelector('#rating-value') as HTMLElement; ratingSlider?.addEventListener('input', () => (ratingValue.textContent = `≥ ${ratingSlider.value}`)); // Apply filters - clean API, no type casting needed! content.querySelector('#apply-filters')?.addEventListener('click', () => { const plugin = grid.getPlugin?.(FilteringPlugin); if (!plugin) return; plugin.clearAllFilters(); const dept = (content.querySelector('#dept-filter') as HTMLSelectElement).value; if (dept) plugin.setFilter('department', { type: 'text', operator: 'equals', value: dept }); const levels = Array.from(content.querySelectorAll('.level-filter:checked')).map( (el) => (el as HTMLInputElement).value, ); if (levels.length) plugin.setFilter('level', { type: 'set', operator: 'in', value: levels }); const statuses = Array.from(content.querySelectorAll('.status-filter:checked')).map( (el) => (el as HTMLInputElement).value, ); if (statuses.length) plugin.setFilter('status', { type: 'set', operator: 'in', value: statuses }); const minRating = parseFloat(ratingSlider.value); if (minRating > 0) plugin.setFilter('rating', { type: 'number', operator: 'greaterThanOrEqual', value: minRating }); if ((content.querySelector('#top-performer-filter') as HTMLInputElement).checked) { plugin.setFilter('isTopPerformer', { type: 'boolean', operator: 'equals', value: true }); } }); // Clear filters content.querySelector('#clear-filters')?.addEventListener('click', () => { grid.getPlugin?.(FilteringPlugin)?.clearAllFilters(); (content.querySelector('#dept-filter') as HTMLSelectElement).value = ''; content.querySelectorAll('.level-filter, .status-filter').forEach((input) => { (input as HTMLInputElement).checked = false; input.closest('.filter-pill')?.classList.remove('filter-pill--active'); }); ratingSlider.value = '0'; ratingValue.textContent = '≥ 0'; (content.querySelector('#top-performer-filter') as HTMLInputElement).checked = false; }); return () => content.remove(); }, }); } /** * Registers the Analytics tool panel. */ export function registerAnalyticsPanel(grid: GridElement): void { grid.getPluginByName('shell')?.registerToolPanel({ id: 'analytics', title: 'Analytics', icon: '📈', tooltip: 'View data analytics and insights', order: 20, render: (container: HTMLElement) => { const rows = grid.rows || []; const totalSalary = rows.reduce((sum, r) => sum + r.salary, 0); const avgSalary = totalSalary / rows.length; const avgRating = rows.reduce((sum, r) => sum + r.rating, 0) / rows.length; const topPerformers = rows.filter((r) => r.isTopPerformer).length; const deptCounts = rows.reduce( (acc, r) => ({ ...acc, [r.department]: (acc[r.department] || 0) + 1 }), {} as Record, ); const sortedDepts = Object.entries(deptCounts).sort((a, b) => b[1] - a[1]); const largestDept = sortedDepts[0]; const formatCurrency = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); const content = document.createElement('div'); content.className = 'analytics-content'; content.innerHTML = `
Total Payroll
${formatCurrency(totalSalary)}
Avg Salary
${formatCurrency(avgSalary)}
Avg Rating
${avgRating.toFixed(1)} ★
Top Performers
${topPerformers}

Department Distribution

${sortedDepts .slice(0, 6) .map( ([dept, count]) => `
${dept}
${count}
`, ) .join('')}
Largest Department
${largestDept?.[0] || 'N/A'} (${largestDept?.[1] || 0} employees)
`; container.appendChild(content); return () => content.remove(); }, }); } ``` ```ts // demos/vanilla/src/demos/employee-management/grid-factory.ts /** * Employee Management Grid Factory * * Pure factory function that creates a fully configured employee grid element. * Decoupled from the route shell so the docs site can call `createEmployeeGrid()` * directly via the `@demo/vanilla` alias. */ // Import shared demo styles (applies to document) import '@demo/shared/employee-management/demo-styles.css'; // Import the grid component (registers custom element) import '@toolbox-web/grid'; // Import grid factory and plugins import { createGrid, type DataGridElement } from '@toolbox-web/grid/all'; // Import shared data generators and types import { generateEmployees, type Employee } from '@demo/shared/employee-management'; // Import grid configuration from separate file import { createGridConfig, type GridConfigOptions } from './grid-config'; // Import tool panel registration import { injectToolPanelStyles, registerAnalyticsPanel, registerQuickFiltersPanel } from './tool-panels'; /** * Options for creating an employee grid. * Extends GridConfigOptions with row count. */ export interface EmployeeGridOptions extends GridConfigOptions { rowCount: number; } /** * Creates a fully configured employee management grid. * * @param options - Configuration options for the grid * @returns The configured grid element */ export function createEmployeeGrid(options: EmployeeGridOptions): DataGridElement { const { rowCount, ...configOptions } = options; // Create the grid element using the typed factory function const grid = createGrid(); grid.id = 'employee-grid'; grid.className = 'demo-grid'; // Create toolbar buttons container (users have full control over button HTML) const toolButtons = document.createElement('tbw-grid-tool-buttons'); const exportCsvBtn = document.createElement('button'); exportCsvBtn.className = 'tbw-toolbar-btn'; exportCsvBtn.setAttribute('title', 'Export CSV'); exportCsvBtn.setAttribute('aria-label', 'Export CSV'); exportCsvBtn.textContent = '📄'; exportCsvBtn.onclick = () => grid.getPluginByName?.('export')?.exportCsv?.({ fileName: 'employees' }); const exportExcelBtn = document.createElement('button'); exportExcelBtn.className = 'tbw-toolbar-btn'; exportExcelBtn.setAttribute('title', 'Export Excel'); exportExcelBtn.setAttribute('aria-label', 'Export Excel'); exportExcelBtn.textContent = '📊'; exportExcelBtn.onclick = () => grid.getPluginByName?.('export')?.exportExcel?.({ fileName: 'employees' }); toolButtons.appendChild(exportCsvBtn); toolButtons.appendChild(exportExcelBtn); grid.appendChild(toolButtons); // Apply configuration grid.gridConfig = createGridConfig(configOptions); // Set initial data grid.rows = generateEmployees(rowCount); // Register tool panels and inject styles after grid is ready grid.ready?.().then(() => { registerQuickFiltersPanel(grid); registerAnalyticsPanel(grid); grid.refreshShellHeader?.(); injectToolPanelStyles(grid); }); return grid; } // Re-export config helpers so consumers can build their own grid variants export { createGridConfig, type GridConfigOptions } from './grid-config'; ``` --- # Architecture > Internal architecture of @toolbox-web/grid — configuration system, render scheduler, virtualization, plugin lifecycle, and light DOM design. This page describes the internal architecture of `@toolbox-web/grid`. It's intended for contributors, plugin developers, and anyone who wants to understand how the grid works under the hood. ## Design Philosophy 1. **Light DOM** — No Shadow DOM. The grid renders directly into the element, allowing full CSS customization. 2. **Single Source of Truth** — All configuration converges into `effectiveConfig`, which is the only state read by rendering logic. 3. **Plugin-First** — Features like selection, editing, and filtering are plugins, not core code. This keeps the core small and tree-shakeable. 4. **Web Standards** — Built on Custom Elements, CSS Custom Properties, `adoptedStyleSheets`, and standard DOM APIs. 5. **Framework-Agnostic** — Pure TypeScript/HTML, no runtime framework dependencies. ## Component Overview ``` ┌───────────────────────────────────────────────────────┐ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ Light DOM │ │ │ │ ┌───────────────────────────────────────────┐ │ │ │ │ │ Header Row │ │ │ │ │ └───────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────┐ │ │ │ │ │ Rows Viewport (scrollable) │ │ │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ │ │ │ Spacer (virtual scroll height) │ │ │ │ │ │ │ ├─────────────────────────────────────┤ │ │ │ │ │ │ │ Visible Rows (row pool) │ │ │ │ │ │ │ └─────────────────────────────────────┘ │ │ │ │ │ └───────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────┘ ``` ## Component Lifecycle ``` constructor() └── Create internal state objects └── Initialize render scheduler connectedCallback() └── Parse light DOM children (, ) └── Merge configuration (gridConfig + columns + fitMode + light DOM) └── Attach plugins (validate dependencies, call onAttach) └── Render header + rows └── Set up scroll listener, resize observer attributeChangedCallback() └── Map attribute → property setter disconnectedCallback() └── Abort disconnect signal (plugins clean up) └── Remove event listeners └── Disconnect resize observer ``` --- ## Configuration Architecture The grid follows a **single source of truth** pattern. All configuration inputs converge into one `effectiveConfig` object, which is then used for all rendering and behavior. ### Why Single Source of Truth? - **Predictable behavior**: One canonical config means no ambiguity about which setting applies - **Easy debugging**: Inspect `effectiveConfig` to see exactly what the grid is using - **Flexible input**: Users can configure via the method most convenient for their use case - **Plugin-friendly**: Plugins can read/modify config through one consistent interface ### Input Sources Users can configure the grid through multiple input methods: ```mermaid flowchart TB subgraph inputs["INPUT SOURCES"] direction TB A["gridConfig
property"] B["columns
property"] C["fitMode
property"] subgraph lightdom["LIGHT DOM"] D["<tbw-grid-column>
field, header"] E["<tbw-grid-header>
title"] end end A --> F B --> F C --> F D --> F E --> F F["ConfigManager.merge()
single merge point"] F --> G["#effectiveConfig
canonical config
◀ SINGLE SOURCE OF TRUTH"] G --> H["DERIVED STATE
_columns (processed)
_rows (processed)"] ``` ### Precedence Rules When the same property is set via multiple sources, higher precedence wins: | Priority | Source | Example | | ----------- | --------------------- | -------------------------------------------- | | 1 (lowest) | `gridConfig` property | `grid.gridConfig = { fitMode: 'stretch' }` | | 2 | Light DOM elements | `` | | 3 | `columns` property | `grid.columns = [{ field: 'name' }]` | | 4 | Inferred columns | (auto-detected from first row) | | 5 (highest) | Individual props | `grid.fitMode = 'fixed'` | > **Note:** HTML attributes (`rows`, `columns`, `grid-config`, `fit-mode`) invoke the corresponding property setters via `attributeChangedCallback` — they are not a separate precedence layer. ### HTML Attribute Configuration The grid supports JSON-serialized configuration via HTML attributes: ```html ``` Supported attributes: `rows`, `columns`, `grid-config`, `fit-mode`. ### Light DOM Configuration The grid parses these light DOM elements on connection: ```html Custom content here ``` ### Internal State Categories | Category | Example | Description | |----------|---------|-------------| | **Input Properties** | `#rows`, `#columns`, `#gridConfig`, `#fitMode` | Raw user input, stored as-is | | **Effective Config** | `#effectiveConfig` | Merged canonical config — single source of truth | | **Derived State** | `_columns`, `_rows` | Post-plugin-processing state used by rendering | | **Runtime State** | `#hiddenColumns`, `sortState` | User-driven state changes (hide column, sort, etc.) | **Key rule:** Rendering logic reads from `effectiveConfig` or derived state, never from input properties. --- ### Two-Layer Config Architecture ConfigManager implements a two-layer architecture to separate the **original configuration** (from sources) from **runtime mutations**: ```mermaid flowchart TB subgraph sources["SOURCES"] A["gridConfig"] B["columns"] C["fitMode"] D["Light DOM"] E["Shell State Maps"] end A --> F B --> F C --> F D --> F E --> F F["ConfigManager.merge()"] F -->|"sources changed"| G["#originalConfig
(frozen, immutable)"] G -->|"clone"| H["#effectiveConfig
(mutable, runtime changes)"] H -->|"user interaction"| I["Runtime Mutations
hidden, width, sort order"] J["resetState()"] -->|"clone original → effective"| H ``` **Layer 1: Original Config (`#originalConfig`)** - Built from all sources via `#collectAllSources()` - Frozen after creation (`Object.freeze`) - Immutable — never modified after merge - Serves as the "reset point" for the effective config **Layer 2: Effective Config (`#effectiveConfig`)** - Deep cloned from original config - Mutable — runtime changes go here - Column visibility, widths, sort order, etc. - All rendering reads from this layer **Key Behaviors:** | Operation | What Happens | | ----------------------------------------- | ---------------------------------------------------------- | | Source changes (gridConfig, columns, etc.) | `markSourcesChanged()` → `merge()` rebuilds both layers | | Runtime mutation (hide column, resize) | Modify `effectiveConfig` only, `original` untouched | | `resetState()` | Clone `original` → `effective`, discarding runtime changes | | `collectState()` | Diff `effective` vs `original` to get user changes | **When Sources Change:** Sources are re-collected only when `#sourcesChanged` is `true` AND columns already exist: 1. Setting `gridConfig`, `columns`, `fitMode` → auto-marks sources changed 2. Setting Light DOM columns → auto-marks sources changed 3. Shell state updates → call `markSourcesChanged()` explicitly 4. `merge()` is a no-op if sources haven't changed AND columns exist 5. If no columns exist yet, `merge()` always runs (to allow inference from rows) This optimization prevents unnecessary rebuilds when `merge()` is called multiple times per frame. --- ## Rendering Pipeline All rendering flows through a centralized **RenderScheduler** that batches all work into a single `requestAnimationFrame` callback. This eliminates race conditions between different parts of the grid that previously scheduled independent RAFs. ### Render Scheduler Architecture ```mermaid flowchart TB subgraph sources["RENDER REQUESTS"] A["Property Change
(rows, columns, gridConfig)"] B["Framework Adapter
(React/Angular refreshColumns)"] C["ResizeObserver
(container resize)"] D["Scroll Event
(virtualization)"] E["Plugin Request
(afterRender needs)"] end A --> F["RenderScheduler.requestPhase()"] B --> F C --> F D --> G["Direct Call
(hot path)"] E --> F F --> H["Single RAF
#flush()"] H --> I["Phase-Ordered Execution"] subgraph phases["EXECUTION ORDER (data dependencies)"] P1["1. mergeConfig
(FULL/COLUMNS phase)"] P2["2. processRows
(ROWS phase)"] P3["3. processColumns + updateTemplate
(COLUMNS phase)"] P4["4. renderHeader
(HEADER phase)"] P5["5. refreshVirtualWindow
(VIRTUALIZATION phase)"] P6["6. afterRender hooks
(STYLE phase)"] end I --> P1 --> P2 --> P3 --> P4 --> P5 --> P6 ``` ### Render Phases Work is organized into ordered phases. Multiple requests merge to the **highest requested phase**: | Phase | Priority | Work Performed | |-------|----------|----------------| | `STYLE` | 1 | Plugin `afterRender()` hooks only | | `VIRTUALIZATION` | 2 | Recalculate virtual window (+ STYLE) | | `HEADER` | 3 | Re-render header row (+ VIRTUALIZATION) | | `ROWS` | 4 | Rebuild row model (+ HEADER) | | `COLUMNS` | 5 | Process columns, update CSS template (+ ROWS) | | `FULL` | 6 | Merge effective config (+ COLUMNS) | Higher phases implicitly cover all lower phases. Requesting `COLUMNS` when `ROWS` is already pending results in just `COLUMNS` executing. **Example**: If React adapter requests `COLUMNS` and ResizeObserver requests `VIRTUALIZATION` in the same frame, only `COLUMNS` phase runs (which includes all lower phases). ### Execution Order in `#flush()` ```typescript if (phase >= RenderPhase.COLUMNS) mergeConfig(); if (phase >= RenderPhase.ROWS) processRows(); if (phase >= RenderPhase.COLUMNS) processColumns(); if (phase >= RenderPhase.COLUMNS) updateTemplate(); if (phase >= RenderPhase.HEADER) renderHeader(); if (phase >= RenderPhase.VIRTUALIZATION) renderVirtualWindow(); if (phase >= RenderPhase.STYLE) afterRender(); ``` ### Intentional Bypasses Some operations intentionally bypass the scheduler for performance: | Operation | Reason | | ---------------------- | ------------------------------------------ | | Scroll rendering | Hot path — must be synchronous for 60fps | | Shell rebuild | Creates DOM structure, not content updates | | Row height measurement | One-time post-paint measurement | ### Debugging Renders The scheduler is held on a private field (`#scheduler`), so direct introspection from the console is not part of the public API. To trace render activity: - Use the browser **Performance** tab and record around the interaction — each `requestAnimationFrame` callback shows up with the phase work (`processRows`, `processColumns`, `renderHeader`, `renderVirtualWindow`, plugin `afterRender`). - Add `console.log` calls in your own plugin's hooks to confirm which phase fires after a given input. - The `source` argument passed to `requestPhase()` (e.g., `'applyGridConfigUpdate'`, `'resize-observer'`, `'plugin:requestRender'`) is preserved for future debug surfaces; the strings are visible in source files when grepping for `requestPhase(`. --- ## Virtualization ### Row Virtualization (Built-in) The grid maintains a "virtual window" — an offset and count representing which rows are visible: ``` Total rows: 10,000 Viewport: 400px, rowHeight: 28px → ~14 visible rows Overscan: 8 rows above + 8 below = 30 DOM rows in pool Scroll position: 5,000px → startIndex: 178 Rendered: rows 170–200 (30 rows in DOM) ``` - **Row pool**: DOM rows are reused, not created/destroyed on scroll - **Transform-based positioning**: Each row uses `transform: translateY()` for GPU-accelerated positioning - **Range-based updates**: Only rows entering/leaving the viewport get updated content For very small datasets (≤8 rows by default), virtualization is bypassed — the overhead isn't worth it. #### Massive datasets and the browser height cap Browsers cap a single element's rendered height at roughly **33.5M px** (Chromium's `2^25`; Firefox/Safari are similar). With the default `rowHeight: 34`, that's hit at about **986,895 rows**. Above the cap, the faux-vscroll spacer would silently truncate, leaving the tail of the dataset unreachable. To support datasets exceeding the cap (e.g. log viewers with 10M+ rows), the grid switches to **fractional scroll mapping**: - The spacer is clamped at `MAX_ELEMENT_HEIGHT_PX` (≈ 33.5M px). - The native `scrollTop` is treated as a position in the clamped spacer space and translated into a **virtual** offset in raw row-content space: ```text virtualScrollTop = scrollTop * (rawContentHeight − viewport) / (spacerHeight − viewport) ``` - `scrollToRow(N)` reverse-maps the row's offset back into the spacer's `scrollTop` so any row in the dataset is reachable programmatically. For datasets below the cap, mapping is identity — pixel-accurate behavior is unchanged. Above the cap, every row remains reachable via the scrollbar, `Ctrl+End`, and `scrollToRow(N)`. Keyboard cell navigation (`ArrowDown`/`ArrowUp`/`Tab`/`PageDown`/`PageUp`) also stays single-row accurate — focus moves by row index and the auto-scroll that keeps the focused cell visible routes through the mapping. The remaining caveat is **direct scroll input** (mouse wheel, scrollbar drag, `Space`/`Shift+Space`): one pixel of `scrollTop` corresponds to many rows above the cap, so a single wheel notch can skip past hundreds of rows. The mapping helpers (`MAX_ELEMENT_HEIGHT_PX`, `computeScrollMapping`, `toVirtualScrollTop`, `fromVirtualScrollTop`) are exported for plugins that maintain their own scroll-driven DOM state. ### Column Virtualization (Plugin) The `ColumnVirtualizationPlugin` applies the same window concept horizontally. Only columns visible in the horizontal scroll position are rendered. --- ## Plugin System & Feature Registry The plugin lifecycle, hooks, manifest schema, validated properties, communication channels, and the feature registry are documented under [Plugin Development → Architecture](/grid/plugin-development/architecture.md). That page also covers tree-shaking, dependency ordering, and the lazy-hook design that keeps the feature API zero-cost when unused. For a step-by-step tutorial on building your own plugin, see the [Authoring Guide](/grid/plugin-development/custom-plugins.md). --- ## Framework Adapter System How React, Angular, and Vue adapters intercept cell rendering, editor creation, config processing, and cell cleanup is documented under [Framework Adapters → Architecture](/grid/framework-adapters/architecture.md). --- ## Shell System The **shell** is an optional wrapper that adds a header bar, toolbar buttons, and a collapsible tool panel sidebar to the grid. ### Structure ``` ┌──────────────────────────────────────────────────────────┐ │ SHELL HEADER │ │ ┌──────────┬──────────────────┬────────────────────────┐ │ │ │ Title │ Header Content │ Toolbar │ ☰ Toggle │ │ │ └──────────┴──────────────────┴────────────────────────┘ │ ├───────────┬──────────────────────────────────────────────┤ │ TOOL │ │ │ PANEL │ GRID CONTENT │ │ (sidebar) │ │ │ ┌───────┐ │ ┌─────────────────────────────────────┐ │ │ │Section│ │ │ Header Row │ │ │ │ ▼ │ │ ├─────────────────────────────────────┤ │ │ │Content│ │ │ Data Rows (virtualized) │ │ │ │ │ │ │ │ │ │ ├───────┤ │ └─────────────────────────────────────┘ │ │ │Section│ │ │ │ │ ▶ │ │ │ │ └───────┘ │ │ └───────────┴──────────────────────────────────────────────┘ ``` ### Configuration The shell is configured via `gridConfig.shell` or Light DOM elements: ```typescript gridConfig: { shell: { header: { title: 'My Grid' }, toolPanel: { position: 'left' | 'right', // Sidebar position width: '17.5em', // Panel width (CSS value) defaultOpen: 'filters', // Section auto-expanded on first open (v2: also opens sidebar — deprecated, see #259) initialState: 'closed', // 'open' | 'closed' — preferred way to control sidebar open state locked: false, // true = sidebar always open, toggle hidden closeOnClickOutside: false, }, }, } ``` Or declaratively: ```html Custom center content ``` ### Content Types Plugins and application code can register three types of content: | Type | Location | Example | |------|----------|---------| | **ToolPanel** | Accordion sections in sidebar | Filter panel, visibility panel, pivot config | | **HeaderContent** | Center of header bar | Search box, breadcrumbs | | **ToolbarContent** | Right side of header (before toggle) | Export button, view switcher | Each content type uses a render function pattern: ```typescript grid.registerToolPanel({ id: 'filters', title: 'Filters', icon: '🔍', render: (container) => { container.innerHTML = '
Filter UI here
'; return () => { /* cleanup */ }; }, }); ``` ### Shell State The shell maintains runtime state for: - **Panel open/close** — `isPanelOpen`, toggled via `openToolPanel()` / `closeToolPanel()` - **Expanded sections** — `expandedSections: Set`, accordion state - **Content registrations** — `toolPanels`, `headerContents`, `toolbarContents` Maps - **Cleanup functions** — Each rendered content can return a cleanup callback, tracked for disposal ### Lazy Rendering Tool panel section content is rendered **lazily** — the `render()` function only executes when the user expands that accordion section for the first time. This avoids expensive initialization for panels the user never opens. --- ## Animation System The grid supports CSS-driven animations for row insert, remove, and update operations. ### Animation Types | Type | Trigger | Default Duration | CSS Variable | |------|---------|-----------------|--------------| | `'insert'` | `insertRow()`, bulk add | 300ms | `--tbw-row-insert-duration` | | `'remove'` | `removeRow()`, bulk delete | 200ms | `--tbw-row-remove-duration` | | `'change'` | `animateRow()`, data update highlight | 500ms | `--tbw-row-change-duration` | ### How It Works Animations use a **CSS attribute hook** pattern: 1. Grid sets `data-animating="insert"` on the row element 2. CSS transitions/keyframes in `grid.css` target `[data-animating="insert"]` 3. After the duration, a `setTimeout` removes the attribute (or the row for `'remove'`) 4. A `Promise` resolves when the animation completes ```typescript // Core mechanism (row-animation.ts) rowEl.setAttribute('data-animating', animationType); const duration = getAnimationDuration(rowEl, animationType); setTimeout(() => { if (animationType !== 'remove') { rowEl.removeAttribute('data-animating'); } onComplete?.(); }, duration); ``` ### Animation Configuration ```typescript gridConfig: { animation: { mode: 'reduced-motion', // Default: respects prefers-reduced-motion duration: 200, // Sets --tbw-animation-duration easing: 'ease-out', // Sets --tbw-animation-easing }, } ``` **`mode` options:** | Value | Behavior | |-------|----------| | `true` / `'on'` | Always animate | | `false` / `'off'` | Never animate | | `'reduced-motion'` | Respect `prefers-reduced-motion` media query (default) | ### Public API ```typescript // Highlight a row after data update await grid.animateRow(5, 'change'); // Highlight by row ID await grid.animateRowById('emp-123', 'change'); // Animate multiple rows const count = await grid.animateRows([0, 2, 5], 'change'); // Insert with auto-animation (default) await grid.insertRow(0, newRow); // Animates await grid.insertRow(0, newRow, false); // No animation // Remove with auto-animation (default) const removed = await grid.removeRow(5); // Animates fade-out const removed = await grid.removeRow(5, false); // Immediate ``` All methods return `Promise`s — `await` ensures the animation completes before proceeding. ### CSS Customization Override animation timing per-type via CSS custom properties: ```css tbw-grid { --tbw-row-change-duration: 800ms; /* Longer highlight */ --tbw-row-insert-duration: 0ms; /* Disable insert animation */ --tbw-row-remove-duration: 150ms; /* Faster fade-out */ --tbw-row-change-color: #fff3cd; /* Yellow highlight */ } --- ## DOM Structure ```html
Name
Age
Alice
30
``` --- ## Styling Architecture ### CSS Custom Properties All styling uses CSS custom properties for theming. Override defaults to customize: ```css tbw-grid { --tbw-color-bg: #ffffff; --tbw-color-fg: #1a1a1a; --tbw-color-border: #e5e5e5; --tbw-color-header-bg: #f5f5f5; --tbw-row-height: 1.75em; /* ~28px at 16px font */ --tbw-header-height: 1.875em; /* ~30px at 16px font */ --tbw-font-family: system-ui, sans-serif; --tbw-font-size: 1em; } ``` ### Key CSS Architecture - **CSS Nesting**: Styles use `tbw-grid { .data-grid-container { ... } }` for scoping - **Cascade Layers**: `@layer tbw-base, tbw-plugins, tbw-theme` — user styles always win - **Adopted Stylesheets**: Dynamic styles use `document.adoptedStyleSheets` for efficiency — styles survive `replaceChildren()` calls - **`em`-Based Sizing**: Row height, padding, and spacing scale with `font-size` ### Plugin Styles Plugins inject CSS via their `styles` property using adopted stylesheets. They use a layered fallback pattern for flexibility: ```css /* Plugin-specific → Global fallback */ background: var(--tbw-selection-bg, var(--tbw-color-selection)); ``` --- ## Event Flow The grid dispatches `CustomEvent`s for every user-visible action (cell clicks, commits, sort changes, etc.). See the [Events section of the API Reference](/grid/api-reference/#events) for the full list and the auto-generated [`DataGridEventMap`](/grid/api/core/interfaces/datagrideventmap/) for payload types. Internally, plugins communicate through two mechanisms described in the [Plugin Communication](#plugin-communication) section above: the **Event Bus** (async, fire-and-forget) and the **Query System** (sync, request-response). Public `CustomEvent`s are dispatched by the core grid or by individual plugins after their internal processing completes. ### Data Flow Example: Sort ``` User clicks sort header ↓ Header click handler → update sortState ↓ Call processRows() on all plugins (MultiSortPlugin sorts) ↓ _rows updated with sorted order ↓ scheduler.requestPhase(ROWS, 'sort') ↓ requestAnimationFrame ↓ Render: update visible row content from new _rows ``` --- ## Performance ### DOM Optimization | Technique | Benefit | | --- | --- | | **Template Cloning** | 3–4× faster than `createElement` | | **Direct DOM Construction** | Avoids innerHTML parsing overhead | | **DocumentFragment** | Single reflow per row | | **Row Pooling** | Zero allocation during scroll | | **Cached DOM Refs** | Avoid querySelector per scroll | ### Rendering Pipeline | Technique | Benefit | | --- | --- | | **Centralized Scheduler** | Eliminates race conditions | | **Phase-Based Execution** | No duplicate work | | **adoptedStyleSheets** | Runtime CSS injection without child node removal | | **Idle Scheduling** | Faster time-to-interactive | | **Fast-Path Patching** | Skip expensive template logic for plain text | | **Cell Display Cache** | Avoid recomputing during scroll | ### Event Handling | Technique | Benefit | | --- | --- | | **Event Delegation** | Constant memory regardless of rows | | **Pooled Scroll Events** | Zero GC pressure during scroll | | **AbortController Cleanup** | No memory leaks on disconnect | --- ## Directory Structure ``` libs/grid/src/ ├─ index.ts # Main entry (auto-registers element) ├─ public.ts # Public API surface (types, constants) ├─ all.ts # All-in-one bundle with all plugins └─ lib/ ├─ core/ │ ├─ grid.ts # Main component class (~4500 lines) │ ├─ grid.css # Core styles │ ├─ types.ts # Public type definitions │ ├─ constants.ts # DOM class/attribute constants │ ├─ internal/ # Pure helper functions │ │ ├─ columns.ts # Column resolution, sizing │ │ ├─ config-manager.ts # Two-layer config (original + effective) │ │ ├─ dom-builder.ts # Direct DOM construction │ │ ├─ render-scheduler.ts # RAF-based render batching │ │ ├─ rows.ts # Row rendering + cell patching │ │ ├─ row-animation.ts # CSS-driven row animations │ │ ├─ shell.ts # Shell header, toolbar, tool panels │ │ ├─ virtualization.ts # Virtual scroll math │ │ ├─ feature-hook.ts # Lazy bridge to feature registry │ │ └─ ... # More internal helpers │ ├─ plugin/ # Plugin infrastructure │ │ ├─ base-plugin.ts # Abstract base class │ │ └─ plugin-manager.ts # Plugin lifecycle │ └─ styles/ # Core CSS (variables, layers) │ └─ variables.css # CSS custom property defaults ├─ features/ # Feature registry (declarative plugin API) │ ├─ registry.ts # registerFeature(), createPluginsFromFeatures() │ ├─ selection.ts # Side-effect: registers selection feature │ ├─ editing.ts # Side-effect: registers editing feature │ └─ ... # 25 feature modules total └─ plugins/ # Built-in plugins ├─ editing/ # Inline cell editing ├─ filtering/ # Column filters ├─ selection/ # Row/cell/range selection ├─ multi-sort/ # Multi-column sorting └─ ... # 24 plugins total ``` ## See Also - [Custom Plugins](/grid/plugin-development/custom-plugins.md) — Build your own plugins with hooks, events, and queries - [Performance Guide](/grid/guides/performance.md) — Render scheduler tuning, virtualization, idle scheduling - [Plugins Overview](/grid/plugins.md) — Available plugins and their hooks - [Theming Guide](/grid/guides/theming.md) — CSS custom properties, themes, cascade layers - [API Reference](/grid/api-reference.md) — Properties, methods, events, and types --- # API Reference > Complete reference for the component — properties, methods, events, CSS custom properties, keyboard shortcuts, declarative configuration, and accessibility. Complete reference for the `` component API. ## Element Tag ```html ``` The custom element is registered as `tbw-grid` (Toolbox Web Grid). ## Properties ### Core Data Properties | Property | Type | Default | Description | | ------------ | ---------------- | ------- | -------------------------- | | `rows` | `T[]` | `[]` | Array of row data objects | | `columns` | [`ColumnConfig[]`](/grid/api/core/interfaces/columnconfig.md) | `[]` | Column configuration array | | `gridConfig` | [`GridConfig`](/grid/api/core/interfaces/gridconfig.md) | `{}` | Full configuration object | ### Layout Properties | Property | Type | Default | Description | | --------- | ---------------------- | ----------- | ---------------------- | | `fitMode` | `'stretch' \| 'fixed'` | `'stretch'` | Column sizing strategy | ### State Properties | Property | Type | Default | Description | | ------------- | --------------------------- | ------- | ------------------------------------------- | | `loading` | `boolean` | `false` | Grid-level loading state. Customise the indicator via [`gridConfig.loadingRenderer`](/grid/api/core/types/loadingrenderer.md) | | `columnState` | [`GridColumnState[]`](/grid/api/core/interfaces/gridcolumnstate.md) | - | Get/set column widths, order, visibility, sort | ### Read-only Properties | Property | Type | Description | | ------------- | ---------------------------- | ----------------------------- | | `changedRows` | `T[]` | Rows with pending edits (requires EditingPlugin) | | `sortState` | `Map` | Current sort state per column | ## Declarative Configuration The grid supports two forms of declarative HTML configuration: 1. **HTML Attributes** — JSON-serialized values on the `` element itself 2. **Light DOM Elements** — Child elements nested inside `` for more readable markup Both approaches can be combined. Light DOM elements take precedence over JSON attributes for the same configuration (e.g., `` elements override the `columns` attribute). :::note JavaScript property assignment always takes precedence over declarative configuration. Removing an attribute or element does not clear a JS-set value. ::: ### HTML Attributes Attributes on the `` element for simple or programmatically-generated configuration: | Attribute | Maps to Property | Type | Description | | ------------- | ---------------- | ------ | -------------------------- | | `rows` | `rows` | JSON | Row data as JSON array | | `columns` | `columns` | JSON | Column config as JSON array| | `grid-config` | `gridConfig` | JSON | Full config object as JSON | | `fit-mode` | `fitMode` | string | `'stretch'` or `'fixed'` | ```html ``` ### Plugin Host Attributes (`data-*`) Some plugins read their own `data-*` attributes directly from the `` host element when they attach. These are namespaced under `data-` to avoid colliding with the grid's own reactive attributes (`rows`, `columns`, `grid-config`, `fit-mode`, `loading`). They are read **once at attach time** — changing them afterward has no effect (use the JSON `grid-config` attribute or a JS property for reactive updates). | Attribute | Owning plugin | Description | | ---------- | ------------------ | -------------------------------------------------------------------------------------------------- | | `data-src` | [`ServerSidePlugin`](/grid/plugins/server-side.md) | URL to fetch row data from — enables a zero-JS server-fetched grid. See [Server-Side → Declarative `data-src`](/grid/plugins/server-side.md#declarative-data-src-zero-js). | ### Light DOM Elements Child elements inside `` for more readable, template-friendly configuration. Preferred for framework templates (Angular, Vue, etc.) and complex column setups with custom renderers/editors. #### Column Elements ##### `` Define column configuration declaratively. | Attribute | Type | Description | | -------------- | --------- | ---------------------------------------------------------------- | | `field` | `string` | **Required.** Data field key | | `header` | `string` | Column header text | | `type` | `string` | Column type. Built-ins: `'string'`, `'number'`, `'boolean'`, `'date'`, `'select'`. Any custom/plugin-registered type string is also accepted. | | `width` | `string` | Column width (e.g., `"120"`, `"100px"`, `"20%"`) | | `min-width` | `number` | Minimum column width in pixels | | `sortable` | `boolean` | Enable sorting (presence = true) | | `resizable` | `boolean` | Enable resizing (presence = true) | | `order` | `number` | Initial column position (0-based index; must be non-negative integer). Columns without `order` fill positions in declaration order. Only affects initial render; user reorder takes precedence. | | `editable` | `boolean` | Enable editing (presence = true, requires EditingPlugin) | | `options` | `string` | Select options: `"value1:Label1,value2:Label2"` or `"val1,val2"` | | `pinned` | `string` | Pin the column to an edge: `'left'` / `'start'` or `'right'` / `'end'` (requires PinnedColumnsPlugin). | | `hidden` | `boolean` | Hide the column initially (presence = true; `hidden="false"` keeps it visible). Requires VisibilityPlugin. | | `lock-visible` | `boolean` | Prevent the column from being hidden via the visibility panel (presence = true). Requires VisibilityPlugin. | > Plugin-contributed attributes (`pinned`, `hidden`, `lock-visible`, `editable`, ``) are read by their owning plugin when it is registered. They set the column's **initial** state — subsequent runtime changes (e.g. `setColumnVisible()`, drag-to-pin) take precedence. ```html ``` ##### `` Custom view template inside a column. Content is used as the cell renderer. ```html {{ value }} ``` ##### `` Custom editor template inside a column (requires EditingPlugin). ```html ``` ##### `` Custom header template inside a column. ```html Status * ``` #### Shell Elements ##### `` Configure the shell header bar. | Attribute | Type | Description | | --------- | -------- | ------------------------ | | `title` | `string` | Grid title (left side) | ```html 20 employees ``` ##### `` Custom content in the shell header center area. Must be nested inside ``. ```html ``` ##### `` Container for toolbar buttons (right side of shell header). ```html ``` #### Tool Panel Elements ##### `` Define a custom tool panel for the sidebar. | Attribute | Type | Description | | --------- | -------- | ---------------------------------------------- | | `id` | `string` | **Required.** Unique panel identifier | | `title` | `string` | **Required.** Panel title in accordion header | | `icon` | `string` | Icon for accordion header (emoji or text) | | `tooltip` | `string` | Tooltip for accordion header | | `order` | `number` | Panel order priority (lower = first, default: 100) | ```html
``` #### Plugin-Specific Elements ##### `` (MasterDetailPlugin) Define the detail panel template for master-detail rows. Requires the MasterDetailPlugin. | Attribute | Type | Description | | --------------------- | ----------------------------- | ---------------------------------------------------- | | `animation` | `'slide' \| 'fade' \| false` | Panel expand/collapse animation (default: `'slide'`) | | `show-expand-column` | `boolean` | Show expand/collapse column (default: `true`) | | `expand-on-row-click` | `boolean` | Expand when row clicked (default: `false`) | | `height` | `number \| 'auto'` | Panel height in pixels or auto (default: `'auto'`) | ```html

{{ row.name }}

{{ row.description }}

``` See the [MasterDetailPlugin documentation](/grid/plugins/master-detail.md) for more details. ##### `` (ResponsivePlugin) Define a custom card template for responsive mode. Requires the ResponsivePlugin. | Attribute | Type | Description | | ----------------- | -------------------- | ------------------------------------------------------- | | `breakpoint` | `number` | Width threshold in pixels for responsive mode | | `card-row-height` | `number \| 'auto'` | Card height in pixels or auto (default: `'auto'`) | | `hidden-columns` | `string` | Comma-separated field names to hide in card mode | | `hide-header` | `boolean` | Hide header row in responsive mode (default: `true`) | | `debounce-ms` | `number` | Resize debounce delay in ms (default: `100`) | ```html
{{ row.name }} {{ row.email }}
``` See the [ResponsivePlugin documentation](/grid/plugins/responsive.md) for more details. :::note Framework adapters may provide wrapper components for these elements. For example, `@toolbox-web/grid-react` provides `` and `` React components with render prop support. See the [React adapter docs](/grid/react/getting-started.md) for details. ::: ## Helper Functions ```typescript import { createGrid, queryGrid } from '@toolbox-web/grid'; // Create a new grid element with configuration const grid = createGrid({ columns: [{ field: 'name' }, { field: 'email' }], fitMode: 'stretch', }); document.body.appendChild(grid); grid.rows = employees; // Query an existing grid with type safety const existingGrid = queryGrid('#my-grid'); if (existingGrid) { existingGrid.rows = newData; } ``` ## Methods ### Data Methods ```typescript // Update row data grid.rows = newData; // Wait for grid to be ready await grid.ready(); // Force layout recalculation await grid.forceLayout(); // Get current configuration (readonly) const config = await grid.getConfig(); ``` ### Row Update API The Row Update API provides ID-based access to individual rows for reading and updating data. This is especially useful when you need to update rows after external changes (e.g., API responses, WebSocket events) without replacing the entire dataset. #### Row Identification The grid automatically determines row IDs using these sources (in order of precedence): 1. `getRowId` function in gridConfig (custom ID resolution) 2. `id` property on the row object 3. `_id` property on the row object :::note Rows without any of these will not be accessible via the Row Update API. Calls to `getRow()` or `updateRow()` for such rows will return `undefined` or have no effect. ::: ```typescript // Configure custom row ID resolution grid.gridConfig = { columns: [...], getRowId: (row) => row.employeeId, // Use a custom field as ID }; // Get a row's ID const id = grid.getRowId(row); console.log(id); // "EMP-123" ``` #### Reading Rows ```typescript // Get a single row by its ID const employee = grid.getRow('EMP-123'); if (employee) { console.log(employee.name); } // Returns undefined if the row is not found const missing = grid.getRow('unknown-id'); console.log(missing); // undefined ``` #### Updating Rows ```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' } }, ]); // Specify the update source (default: 'api') // Valid sources: 'user' | 'cascade' | 'api' grid.updateRow('EMP-123', { status: 'inactive' }, 'api'); grid.updateRows(updates, 'api'); ``` #### Listening to Row Updates The `cell-change` event fires whenever row data is updated via the Row Update API: ```typescript grid.on('cell-change', ({ rowId, changes, source, row }) => { console.log(`Row ${rowId} updated via ${source}:`); console.log('Changes:', changes); // { status: 'active' } console.log('Full row:', row); // Complete row object after update }); ``` :::note The `cell-change` event is distinct from `cell-commit`, which fires during inline editing. Use `cell-change` for programmatic updates via `updateRow()`/`updateRows()`. ::: ### Column Methods ```typescript // Get all columns with visibility info const columns = grid.getAllColumns(); // Returns: Array<{ field, header, visible, lockVisible? }> // Show/hide columns grid.setColumnVisible('fieldName', false); grid.toggleColumnVisibility('fieldName'); grid.showAllColumns(); // Check column visibility const isVisible = grid.isColumnVisible('fieldName'); // Reorder columns grid.setColumnOrder(['id', 'name', 'email']); const order = grid.getColumnOrder(); ``` ### Editing Methods Bulk-edit lifecycle, change tracking, and active-row controls are provided by the **Editing plugin** and surfaced on the grid instance when the plugin is loaded. See [Editing Plugin → Imperative Bulk-Edit API](/grid/plugins/editing.md#imperative-bulk-edit-api) for the full method list and examples. ### Plugin Methods | Method | Returns | Description | | ------------------------ | ------------------------------ | ------------------------------------------------------ | | `getPluginByName(name)` | `Plugin \| undefined` | Get plugin instance by name — **preferred** (type-safe, no import needed) | | `getPlugin(PluginClass)` | `P \| undefined` | Get plugin instance by class (requires import) | `getPluginByName` is type-safe for all built-in plugins via the `PluginNameMap` interface — TypeScript automatically returns the correct plugin type (e.g., `SelectionPlugin` for `'selection'`). ```typescript // Preferred — type-safe, no import needed const selection = grid.getPluginByName('selection'); selection?.selectAll(); // ✅ SelectionPlugin methods are available // Alternative — get by class (requires plugin import) import { SelectionPlugin } from '@toolbox-web/grid/plugins/selection'; const sel = grid.getPlugin(SelectionPlugin); // Check if plugin is registered if (selection) { selection.selectAll(); } ``` ### Custom Styles API The grid uses **light DOM** — standard CSS targeting elements inside `` works normally (global stylesheets, `