Skip to content

Architecture

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.

  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.
┌───────────────────────────────────────────────────────┐
│ <tbw-grid> │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Light DOM │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Header Row │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Rows Viewport (scrollable) │ │ │
│ │ │ ┌─────────────────────────────────────┐ │ │ │
│ │ │ │ Spacer (virtual scroll height) │ │ │ │
│ │ │ ├─────────────────────────────────────┤ │ │ │
│ │ │ │ Visible Rows (row pool) │ │ │ │
│ │ │ └─────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
constructor()
└── Create internal state objects
└── Initialize render scheduler
connectedCallback()
└── Parse light DOM children (<tbw-grid-column>, <tbw-grid-header>)
└── 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

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.

  • 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

Users can configure the grid through multiple input methods:

flowchart TB
    subgraph inputs["INPUT SOURCES"]
        direction TB
        A["gridConfig<br/>property"]
        B["columns<br/>property"]
        C["fitMode<br/>property"]

        subgraph lightdom["LIGHT DOM"]
            D["&lt;tbw-grid-column&gt;<br/>field, header"]
            E["&lt;tbw-grid-header&gt;<br/>title"]
        end
    end

    A --> F
    B --> F
    C --> F
    D --> F
    E --> F

    F["ConfigManager.merge()<br/><i>single merge point</i>"]
    F --> G["#effectiveConfig<br/><i>canonical config</i><br/>◀ SINGLE SOURCE OF TRUTH"]
    G --> H["DERIVED STATE<br/>_columns (processed)<br/>_rows (processed)"]

When the same property is set via multiple sources, higher precedence wins:

PrioritySourceExample
1 (lowest)gridConfig propertygrid.gridConfig = { fitMode: 'stretch' }
2Light DOM elements<tbw-grid-column field="name">
3columns propertygrid.columns = [{ field: 'name' }]
4Inferred columns(auto-detected from first row)
5 (highest)Individual propsgrid.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.

The grid supports JSON-serialized configuration via HTML attributes:

<tbw-grid
rows='[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]'
columns='[{"field":"id","header":"ID"},{"field":"name","header":"Name"}]'
fit-mode="stretch"
>
</tbw-grid>

Supported attributes: rows, columns, grid-config, fit-mode.

The grid parses these light DOM elements on connection:

<tbw-grid>
<!-- Column definitions (→ effectiveConfig.columns) -->
<tbw-grid-column field="name" header="Name" sortable></tbw-grid-column>
<tbw-grid-column field="age" header="Age" type="number"></tbw-grid-column>
<!-- Shell header (→ effectiveConfig.shell.header) -->
<tbw-grid-header title="My Data Grid">
<tbw-grid-header-content>
<span>Custom content here</span>
</tbw-grid-header-content>
<tbw-grid-tool-button label="Refresh" icon="🔄"></tbw-grid-tool-button>
</tbw-grid-header>
</tbw-grid>
CategoryExampleDescription
Input Properties#rows, #columns, #gridConfig, #fitModeRaw user input, stored as-is
Effective Config#effectiveConfigMerged canonical config — single source of truth
Derived State_columns, _rowsPost-plugin-processing state used by rendering
Runtime State#hiddenColumns, sortStateUser-driven state changes (hide column, sort, etc.)

Key rule: Rendering logic reads from effectiveConfig or derived state, never from input properties.


ConfigManager implements a two-layer architecture to separate the original configuration (from sources) from runtime mutations:

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<br/><i>(frozen, immutable)</i>"]
    G -->|"clone"| H["#effectiveConfig<br/><i>(mutable, runtime changes)</i>"]

    H -->|"user interaction"| I["Runtime Mutations<br/>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:

OperationWhat Happens
Source changes (gridConfig, columns, etc.)markSourcesChanged()merge() rebuilds both layers
Runtime mutation (hide column, resize)Modify effectiveConfig only, original untouched
resetState()Clone originaleffective, 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.


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.

flowchart TB
    subgraph sources["RENDER REQUESTS"]
        A["Property Change<br/>(rows, columns, gridConfig)"]
        B["Framework Adapter<br/>(React/Angular refreshColumns)"]
        C["ResizeObserver<br/>(container resize)"]
        D["Scroll Event<br/>(virtualization)"]
        E["Plugin Request<br/>(afterRender needs)"]
    end

    A --> F["RenderScheduler.requestPhase()"]
    B --> F
    C --> F
    D --> G["Direct Call<br/>(hot path)"]
    E --> F

    F --> H["Single RAF<br/>#flush()"]
    H --> I["Phase-Ordered Execution"]

    subgraph phases["EXECUTION ORDER (data dependencies)"]
        P1["1. mergeConfig<br/>(FULL/COLUMNS phase)"]
        P2["2. processRows<br/>(ROWS phase)"]
        P3["3. processColumns + updateTemplate<br/>(COLUMNS phase)"]
        P4["4. renderHeader<br/>(HEADER phase)"]
        P5["5. refreshVirtualWindow<br/>(VIRTUALIZATION phase)"]
        P6["6. afterRender hooks<br/>(STYLE phase)"]
    end

    I --> P1 --> P2 --> P3 --> P4 --> P5 --> P6

Work is organized into ordered phases. Multiple requests merge to the highest requested phase:

PhasePriorityWork Performed
STYLE1Plugin afterRender() hooks only
VIRTUALIZATION2Recalculate virtual window (+ STYLE)
HEADER3Re-render header row (+ VIRTUALIZATION)
ROWS4Rebuild row model (+ HEADER)
COLUMNS5Process columns, update CSS template (+ ROWS)
FULL6Merge 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).

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

Some operations intentionally bypass the scheduler for performance:

OperationReason
Scroll renderingHot path — must be synchronous for 60fps
Shell rebuildCreates DOM structure, not content updates
Row height measurementOne-time post-paint measurement
// In browser console
grid._scheduler.setDebug(true);
// After interactions, inspect:
grid._scheduler.getRenderLog();
// → [{ phase: 5, source: 'applyGridConfigUpdate', timestamp: 1234.56 }, ...]

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.

The ColumnVirtualizationPlugin applies the same window concept horizontally. Only columns visible in the horizontal scroll position are rendered.


flowchart TB
    A["new Plugin()<br/><i>Constructor stores user config</i>"] --> B["attach(grid)<br/><i>Plugin receives grid ref, merges config</i>"]
    B --> C["Grid Render Cycle"]
    C --> D["User Interaction"]
    D --> E["detach()<br/><i>Cleanup on grid disconnect</i>"]

    subgraph C["Grid Render Cycle"]
        C1["processColumns() hook"]
        C2["processRows() hook"]
        C3["afterCellRender() hook (per cell)"]
        C4["afterRowRender() hook (per row)"]
        C5["afterRender() hook"]
    end

    subgraph D["User Interaction"]
        D1["onCellClick() hook"]
        D2["onKeyDown() hook"]
        D3["onScroll() hook"]
        D4["onCellMouseDown/Move/Up()"]
    end
import { BaseGridPlugin, CellClickEvent } from '@toolbox-web/grid';
import styles from './my-plugin.css?inline';
interface MyPluginConfig {
enabled?: boolean;
}
export class MyPlugin extends BaseGridPlugin<MyPluginConfig> {
readonly name = 'myPlugin';
override readonly styles = styles;
protected override get defaultConfig(): Partial<MyPluginConfig> {
return { enabled: true };
}
override attach(grid: GridElement): void {
super.attach(grid); // MUST call super
// Setup listeners with this.disconnectSignal for auto-cleanup
}
override afterRender(): void {
if (!this.config.enabled) return;
// Access DOM via this.gridElement
}
override onCellClick(event: CellClickEvent): boolean | void {
// Return true to prevent default behavior
}
}

Event Bus (async, fire-and-forget):

// Emit
this.emitPluginEvent('selection-cleared', { source: 'keyboard' });
// Listen
this.onPluginEvent('selection-cleared', (detail) => { /* ... */ });

Query System (sync, request-response):

// Register handler
this.registerQuery('getSelectedRanges', () => this.#ranges);
// Query from another plugin
const ranges = this.queryPlugin('getSelectedRanges');

Plugins declare owned config properties via a static manifest:

static override readonly manifest = {
ownedProperties: ['editable', 'editor', 'editOn'],
configRules: [
{ property: 'editable', type: 'column', rule: 'requires-plugin' },
],
};

The grid validates at runtime that properties like editable: true are only used when the owning plugin is loaded, and provides helpful error messages with import hints.

PropertyRequired PluginLevel
editableEditingPluginColumn
editorEditingPluginColumn
editorParamsEditingPluginColumn
groupGroupingColumnsPluginColumn
pinnedPinnedColumnsPluginColumn
columnGroupsGroupingColumnsPluginConfig

Validation runs in the RenderScheduler.mergeConfig callback, after plugins are initialized. Error messages clearly state which plugin is missing and how to import it.


The features API is the recommended way to enable grid capabilities. It wraps the plugin system with declarative configuration and tree-shakeable side-effect imports.

AspectFeatures (recommended)Plugins (advanced)
APIfeatures: { selection: 'row' }plugins: [new SelectionPlugin({ mode: 'row' })]
Importimport '@toolbox-web/grid/features/selection'import { SelectionPlugin } from '@toolbox-web/grid/plugins/selection'
DependenciesAuto-resolvedManual ordering
Tree-shakingZero cost if unusedMust avoid importing unused plugins

Each feature module is a side-effect import that registers a factory function:

flowchart LR
    subgraph imports["SIDE-EFFECT IMPORTS"]
        A["import '.../features/selection'"]
        B["import '.../features/editing'"]
    end

    A -->|"module executes"| C["registerFeature('selection', factory)"]
    B -->|"module executes"| D["registerFeature('editing', factory)"]

    C --> E["featureRegistry<br/><i>Map&lt;string, factory&gt;</i>"]
    D --> E

    F["gridConfig.features"] --> G["createPluginsFromFeatures()"]
    E --> G
    G --> H["Plugin instances<br/>(ordered by dependencies)"]

When a feature module is imported, it runs immediately at load time:

libs/grid/src/lib/features/selection.ts
import { SelectionPlugin } from '../plugins/selection';
import { registerFeature } from './registry';
registerFeature('selection', (config) => {
// Shorthand strings → full config
if (config === 'cell' || config === 'row' || config === 'range') {
return new SelectionPlugin({ mode: config });
}
return new SelectionPlugin(config ?? undefined);
});

Each factory handles shorthand values (e.g., 'row'{ mode: 'row' }), so users get a simpler API.

The grid core never references the feature registry directly. Instead, a hook function bridges them:

// feature-hook.ts — imported by grid core
export let resolveFeatures: FeatureResolverFn | undefined; // Starts undefined!
// registry.ts — imported only when a feature is imported
setFeatureResolver(createPluginsFromFeatures); // Sets the hook

If no feature modules are imported, resolveFeatures stays undefined — the entire registry module is tree-shaken away by the bundler. This is why features are zero-cost when unused.

During plugin initialization:

// grid.ts — #initializePlugins()
const features = this.#effectiveConfig?.features;
if (features && resolveFeatures) {
featurePlugins = resolveFeatures(features);
}
// Feature plugins ordered first, then explicit plugins
const allPlugins = [...featurePlugins, ...explicitPlugins];

Dependencies are auto-ordered: selection and editing always instantiate first, since other plugins (like clipboard or undoRedo) depend on them.

Feature modules are marked as side-effects in package.json:

{ "sideEffects": ["./lib/features/*.js"] }

This tells bundlers: “these imports have side effects (registration), don’t optimize them away.” But un-imported features are never loaded, so only the features you use add to bundle size (~200–300 bytes each).


Framework adapters let React, Angular, and Vue intercept grid rendering to support JSX components, Angular templates, and Vue slots as cell renderers and editors.

flowchart TB
    subgraph adapters["REGISTERED ADAPTERS"]
        A["ReactGridAdapter"]
        B["AngularGridAdapter"]
        C["VueGridAdapter"]
    end

    D["DataGridElement.registerAdapter()"]
    A --> D
    B --> D
    C --> D
    D --> E["Static adapter array<br/><i>(shared across all grids)</i>"]

    subgraph rendering["RENDERING PIPELINE"]
        F["Parse &lt;tbw-grid-column&gt;"]
        G["Cell render cycle"]
        H["Cell cleanup"]
    end

    E --> F
    E --> G
    E --> H

Adapters implement these methods:

MethodRequiredPurpose
canHandle(element)YesCheck if this adapter can process the element
createRenderer(element)YesReturn a cell renderer function, or undefined to pass
createEditor(element)YesReturn a cell editor function, or undefined to pass
processConfig(config)NoTransform framework-specific config (e.g., component classes → render functions)
getTypeDefault(type)NoProvide app-wide type defaults from framework’s DI/registry
releaseCell(cellEl)NoCleanup when cell DOM is recycled (unmount React roots, destroy Angular views)
createToolPanelRenderer(element)NoSupport framework components in tool panel sidebar

Adapters register statically on DataGridElement — typically at module load time:

// React: registers immediately on import
const globalAdapter = new GridAdapter();
DataGridElement.registerAdapter(globalAdapter);
// Angular: registers via Grid directive
DataGridElement.registerAdapter(this.adapter);
// Vue: registers in DataGrid component onMounted()

This is why importing @toolbox-web/grid-react (or any adapter package) auto-registers the adapter — no separate setup needed.

Adapters are called at four points in the grid lifecycle:

1. Light DOM Column Parsing — When <tbw-grid-column> elements are parsed, each adapter is tried until one handles the renderer/editor:

const viewAdapter = adapters.find((a) => a.canHandle(viewTarget));
if (viewAdapter) {
config.viewRenderer = viewAdapter.createRenderer(viewTarget);
}

2. Config Processing — When gridConfig is set, the adapter can transform component classes to render functions:

set gridConfig(value) {
if (value && this.__frameworkAdapter?.processConfig) {
value = this.__frameworkAdapter.processConfig(value);
}
}

3. Cell Rendering — Type defaults are resolved through the adapter:

const appDefault = adapter.getTypeDefault(col.type);
if (appDefault?.renderer) return appDefault.renderer;

4. Cell Cleanup — When virtualization recycles a row, adapters unmount framework components:

grid.__frameworkAdapter?.releaseCell?.(cell);
// React: unmounts React root
// Angular: destroys component view
// Vue: unmounts app instance

The shell is an optional wrapper that adds a header bar, toolbar buttons, and a collapsible tool panel sidebar to the grid.

┌──────────────────────────────────────────────────────────┐
│ SHELL HEADER │
│ ┌──────────┬──────────────────┬────────────────────────┐ │
│ │ Title │ Header Content │ Toolbar │ ☰ Toggle │ │
│ └──────────┴──────────────────┴────────────────────────┘ │
├───────────┬──────────────────────────────────────────────┤
│ TOOL │ │
│ PANEL │ GRID CONTENT │
│ (sidebar) │ │
│ ┌───────┐ │ ┌─────────────────────────────────────┐ │
│ │Section│ │ │ Header Row │ │
│ │ ▼ │ │ ├─────────────────────────────────────┤ │
│ │Content│ │ │ Data Rows (virtualized) │ │
│ │ │ │ │ │ │
│ ├───────┤ │ └─────────────────────────────────────┘ │
│ │Section│ │ │
│ │ ▶ │ │ │
│ └───────┘ │ │
└───────────┴──────────────────────────────────────────────┘

The shell is configured via gridConfig.shell or Light DOM elements:

gridConfig: {
shell: {
header: { title: 'My Grid' },
toolPanel: {
position: 'left' | 'right', // Sidebar position
width: '17.5em', // Panel width (CSS value)
defaultOpen: 'filters', // Auto-open section ID
closeOnClickOutside: false,
},
},
}

Or declaratively:

<tbw-grid>
<tbw-grid-header title="My Grid">
<tbw-grid-header-content>
<span>Custom center content</span>
</tbw-grid-header-content>
<tbw-grid-tool-button label="Export" icon="📥"></tbw-grid-tool-button>
</tbw-grid-header>
</tbw-grid>

Plugins and application code can register three types of content:

TypeLocationExample
ToolPanelAccordion sections in sidebarFilter panel, visibility panel, pivot config
HeaderContentCenter of header barSearch box, breadcrumbs
ToolbarContentRight side of header (before toggle)Export button, view switcher

Each content type uses a render function pattern:

grid.registerToolPanel({
id: 'filters',
title: 'Filters',
icon: '🔍',
render: (container) => {
container.innerHTML = '<div>Filter UI here</div>';
return () => { /* cleanup */ };
},
});

The shell maintains runtime state for:

  • Panel open/closeisPanelOpen, toggled via openToolPanel() / closeToolPanel()
  • Expanded sectionsexpandedSections: Set<string>, accordion state
  • Content registrationstoolPanels, headerContents, toolbarContents Maps
  • Cleanup functions — Each rendered content can return a cleanup callback, tracked for disposal

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.


The grid supports CSS-driven animations for row insert, remove, and update operations.

TypeTriggerDefault DurationCSS Variable
'insert'insertRow(), bulk add300ms--tbw-row-insert-duration
'remove'removeRow(), bulk delete200ms--tbw-row-remove-duration
'change'animateRow(), data update highlight500ms--tbw-row-change-duration

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
// 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);
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:

ValueBehavior
true / 'on'Always animate
false / 'off'Never animate
'reduced-motion'Respect prefers-reduced-motion media query (default)
// 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 Promises — await ensures the animation completes before proceeding.

Override animation timing per-type via CSS custom properties:

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
<tbw-grid aria-label="..." role="grid">
<div class="data-grid-container">
<!-- Header -->
<div role="rowgroup">
<div role="row" aria-rowindex="1">
<div role="columnheader" aria-colindex="1">Name</div>
<div role="columnheader" aria-colindex="2">Age</div>
</div>
</div>
<!-- Body (scrollable, virtualized) -->
<div role="rowgroup" class="data-grid-body">
<div role="row" aria-rowindex="2" style="transform: translateY(0px)">
<div role="gridcell" aria-colindex="1">Alice</div>
<div role="gridcell" aria-colindex="2">30</div>
</div>
</div>
</div>
</tbw-grid>

All styling uses CSS custom properties for theming. Override defaults to customize:

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;
}
  • 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

Plugins inject CSS via their styles property using adopted stylesheets. They use a layered fallback pattern for flexibility:

/* Plugin-specific → Global fallback */
background: var(--tbw-selection-bg, var(--tbw-color-selection));

The grid dispatches CustomEvents for every user-visible action (cell clicks, commits, sort changes, etc.). See the Events section of the API Reference for the full list and the auto-generated DataGridEventMap for payload types.

Internally, plugins communicate through two mechanisms described in the Plugin Communication section above: the Event Bus (async, fire-and-forget) and the Query System (sync, request-response). Public CustomEvents are dispatched by the core grid or by individual plugins after their internal processing completes.

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

TechniqueBenefit
Template Cloning3–4× faster than createElement
Direct DOM ConstructionAvoids innerHTML parsing overhead
DocumentFragmentSingle reflow per row
Row PoolingZero allocation during scroll
Cached DOM RefsAvoid querySelector per scroll
TechniqueBenefit
Centralized SchedulerEliminates race conditions
Phase-Based ExecutionNo duplicate work
adoptedStyleSheetsRuntime CSS injection without child node removal
Idle SchedulingFaster time-to-interactive
Fast-Path PatchingSkip expensive template logic for plain text
Cell Display CacheAvoid recomputing during scroll
TechniqueBenefit
Event DelegationConstant memory regardless of rows
Pooled Scroll EventsZero GC pressure during scroll
AbortController CleanupNo memory leaks on disconnect

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
│ └─ ... # 22 feature modules total
└─ plugins/ # Built-in plugins
├─ editing/ # Inline cell editing
├─ filtering/ # Column filters
├─ selection/ # Row/cell/range selection
├─ multi-sort/ # Multi-column sorting
└─ ... # 22 plugins total
AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://raw.githubusercontent.com/OysteinAmundsen/toolbox/main/llms-full.txt