This commit is contained in:
seb
2026-10-05 22:29:22 +02:00
commit 705bd6d199
26 changed files with 6095 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

48
AGENTS.md Normal file
View File

@@ -0,0 +1,48 @@
# AGENTS — react-virtual-aggridautomat
## What
PureComponent wrapper: AG Grid infinite-scroll + react-automat state. Library in `src/lib/`, demo in `src/demo/`.
## Exports (`src/lib/index.ts`)
- `AutomatGrid` — PureComponent. Props: `automat` (required), `columnDefs` (required), `rowIdField` (default `'id'`), `cacheBlockSize`, `rowBuffer`, `height`, `width`, `onGridApiReady`, plus standard AG Grid overrides.
- `createGridAutomat(options)` — Factory returning `Automat<GridAutomatState, GridAutomatActions>`.
## createGridAutomat
```ts
createGridAutomat({ fetchRows, name?, blockSize? })
```
- `fetchRows: ({startRow, endRow, sortModel, filterModel}) => Promise<{rows, lastRow?}>` — the only required option.
- State: `ready`, `loading`, `totalRows`, `sortModel`, `filterModel`, `revision`, `error`.
- Actions: `reload()`, `setSort(model)`, `setFilter(model)`.
## AutomatGrid internals
- Uses `InfiniteRowModelModule` (community, no enterprise needed).
- Constructor: sync read `automat.state`. Mount: `automat.subscribe(this, selector)`. Unmount: `unsub()`.
- On `revision` change (via `componentDidUpdate`): rebuilds `IDatasource` and calls `api.setGridOption('datasource', ds)`.
- `onSortChanged`/`onFilterChanged` → `automat.actions.setSort/setFilter` → bumps revision → datasource reset.
- `getRowId` uses `props.rowIdField` (default `'id'`) from row data.
## Patterns
```tsx
// 1. Create automat (module scope)
const grid = createGridAutomat({ fetchRows: myFetcher });
// 2. Render
<AutomatGrid automat={grid} columnDefs={cols} rowIdField="id" />
// 3. Reload externally
grid.actions.reload();
// 4. Subscribe from another component
this.unsub = grid.subscribe(this, s => ({ loading: s.loading }));
```
## Rules
- Never use hooks — PureComponent only.
- Subscribe in `componentDidMount`, unsubscribe in `componentWillUnmount`.
- Read `automat.state` in constructor for zero-flash initial render.
- `fetchRows` must return `{ rows, lastRow? }`. Set `lastRow` to total count to stop infinite scrolling.
- AG Grid modules: only `InfiniteRowModelModule` is registered by `AutomatGrid`.
## Commands
```
npm run dev # Vite dev server
npm run build # tsc + vite build
```

12
LICENSE Normal file
View File

@@ -0,0 +1,12 @@
Copyright (C) 2026 by Sebastian Green-Huber
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

232
README.md Normal file
View File

@@ -0,0 +1,232 @@
# ⚡ react-virtual-aggridautomat
> Opinionated, fully-virtualized AG Grid component powered by [react-automat](https://github.com/sebseb7/react-automat) — infinite-scroll data grids as React `PureComponent` with zero hooks, HOCs, or Context Providers.
[![AG Grid](https://img.shields.io/badge/AG_Grid-v36-0084c7?style=flat-square)](https://www.ag-grid.com/)
[![react-automat](https://img.shields.io/badge/react--automat-v0.1-crimson?style=flat-square)](https://github.com/sebseb7/react-automat)
[![React 19](https://img.shields.io/badge/React-19-61dafb?style=flat-square)](https://react.dev/)
---
## What is this?
A reusable `<AutomatGrid />` PureComponent that wires AG Grid's [Infinite Row Model](https://www.ag-grid.com/react-data-grid/infinite-scrolling/) to a `react-automat` store. Your async data fetcher is the only thing you provide — the library handles:
- **Virtual row loading** — only visible rows exist in the DOM
- **Block caching** — configurable cache size, debounced scroll loading
- **Server-side sort & filter** — sort/filter changes flow through the automat and trigger datasource resets
- **Reactive state** — loading indicators, error state, row counts, and revision tracking all live in the automat
- **Zero hooks** — designed for `PureComponent` with synchronous constructor reads and fine-grained automat subscriptions
## Install
```bash
npm install ag-grid-community ag-grid-react react-automat
```
> This library is not yet published to npm. Clone or copy `src/lib/` into your project.
## Quick Start
### 1. Create a grid automat
```ts
// myGridAutomat.ts
import { createGridAutomat } from './lib';
export const myGridAutomat = createGridAutomat({
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
const res = await fetch(`/api/items?start=${startRow}&end=${endRow}`);
const { items, total } = await res.json();
return { rows: items, lastRow: total };
},
name: 'myGrid', // Visible in window.__AUTOMATS__
blockSize: 100, // Rows per block (passed to AG Grid's cacheBlockSize)
});
```
### 2. Render the grid
```tsx
// MyView.tsx
import { PureComponent } from 'react';
import { AutomatGrid } from './lib';
import { myGridAutomat } from './myGridAutomat';
class MyView extends PureComponent {
render() {
return (
<AutomatGrid
automat={myGridAutomat}
columnDefs={[
{ field: 'id', maxWidth: 80 },
{ field: 'name', flex: 2, filter: 'agTextColumnFilter' },
{ field: 'price', valueFormatter: p => `€${p.value?.toFixed(2)}` },
]}
rowIdField="id"
height="600px"
/>
);
}
}
```
### 3. React to grid state
The automat is a standard `react-automat` store — subscribe to it from any PureComponent:
```tsx
class StatusBar extends PureComponent {
constructor(props) {
super(props);
this.state = {
loading: myGridAutomat.state.loading,
totalRows: myGridAutomat.state.totalRows,
};
}
componentDidMount() {
this.unsub = myGridAutomat.subscribe(this, (s) => ({
loading: s.loading,
totalRows: s.totalRows,
}));
}
componentWillUnmount() {
this.unsub();
}
render() {
return (
<div>
{this.state.loading ? 'Loading…' : `${this.state.totalRows} rows`}
<button onClick={() => myGridAutomat.actions.reload()}>↻ Reload</button>
</div>
);
}
}
```
## API Reference
### `createGridAutomat(options)`
Creates a `react-automat` store to back the grid.
| Option | Type | Default | Description |
|---|---|---|---|
| `fetchRows` | `(params: GridFetchParams) => Promise<GridFetchResult>` | **required** | Async function called by AG Grid's datasource for each block |
| `name` | `string` | `null` | Automat registry name (visible in `window.__AUTOMATS__`) |
| `blockSize` | `number` | `100` | Rows per cache block |
#### `GridFetchParams`
```ts
{
startRow: number;
endRow: number;
sortModel: SortModelItem[];
filterModel: Record<string, unknown>;
}
```
#### `GridFetchResult<TRow>`
```ts
{
rows: TRow[];
lastRow?: number; // Total count. undefined/-1 = unknown (infinite scroll continues)
}
```
#### Automat State
| Field | Type | Description |
|---|---|---|
| `ready` | `boolean` | `true` after first block loads |
| `loading` | `boolean` | `true` while any block is in-flight |
| `totalRows` | `number` | Known row count (`-1` if unknown) |
| `sortModel` | `SortModelItem[]` | Current sort state |
| `filterModel` | `Record<string, unknown>` | Current filter state |
| `revision` | `number` | Increments on reload/sort/filter change |
| `error` | `string \| null` | Last fetch error message |
#### Automat Actions
| Action | Description |
|---|---|
| `reload()` | Bumps revision, clears cache, forces full refetch |
| `setSort(sortModel)` | Updates sort model + reloads |
| `setFilter(filterModel)` | Updates filter model + reloads |
### `<AutomatGrid />` Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `automat` | `Automat<GridAutomatState, GridAutomatActions>` | **required** | The grid automat store |
| `columnDefs` | `ColDef[]` | **required** | AG Grid column definitions |
| `defaultColDef` | `ColDef` | `{ flex: 1, minWidth: 100, sortable: true, resizable: true }` | Default column config |
| `rowIdField` | `string` | `'id'` | Row data field used for stable row IDs |
| `cacheBlockSize` | `number` | automat's `blockSize` | Override cache block size |
| `rowBuffer` | `number` | `0` | Extra rows rendered outside viewport |
| `infiniteInitialRowCount` | `number` | `200` | Scrollbar size hint |
| `maxConcurrentDatasourceRequests` | `number` | `2` | Parallel block fetches |
| `maxBlocksInCache` | `number` | `10` | Max cached blocks before eviction |
| `blockLoadDebounceMillis` | `number` | `100` | Scroll debounce |
| `height` | `string \| number` | `'100%'` | Container height |
| `width` | `string \| number` | `'100%'` | Container width |
| `rowHeight` | `number` | AG Grid default (42) | Row height in pixels |
| `theme` | `string` | `'ag-theme-alpine ag-theme-alpine-dark'` | Container theme class |
| `modules` | `Module[]` | InfiniteRowModel + Text/Number/Date/Custom filters | Additional AG Grid modules |
| `onGridApiReady` | `(api: GridApi) => void` | — | Callback when AG Grid API is available |
| `className` | `string` | — | Container CSS class |
| `style` | `CSSProperties` | — | Container inline styles |
## Data Flow
```
┌─────────────────────────────────────────────────────────┐
│ AutomatGrid (PureComponent) │
│ │
│ constructor: reads automat.state synchronously │
│ componentDidMount: subscribes with selector │
│ componentDidUpdate: if revision changed → reset ds │
│ │
│ ┌──────────────┐ ┌────────────────────────┐ │
│ │ AG Grid │────▶│ IDatasource.getRows() │ │
│ │ (infinite │ │ calls automat._fetcher │ │
│ │ row model) │◀────│ → successCallback() │ │
│ └──────────────┘ └────────────────────────┘ │
│ │ │ │
│ onSortChanged ──────▶ automat.actions.setSort() │
│ onFilterChanged ────▶ automat.actions.setFilter() │
│ │ │ │
│ └──── revision bump ─────┘ │
└─────────────────────────────────────────────────────────┘
```
## Running the Demo
```bash
npm install
npm run dev
```
The demo generates 10,000 fake product rows and serves them through a simulated async fetcher with artificial latency. Open http://localhost:5173 to see infinite scroll in action.
## Project Structure
```
src/
├── lib/ # Reusable library
│ ├── createGridAutomat.ts # Automat factory
│ ├── AutomatGrid.tsx # PureComponent grid wrapper
│ └── index.ts # Barrel exports
└── demo/ # Demo app
├── demoAutomat.ts # 10k fake rows + fetcher
└── DemoGrid.tsx # Demo consumer component
```
## License
0BSD

22
eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Opinionated virtualized AG Grid powered by react-automat PureComponent state management" />
<title>AutomatGrid — Virtualized AG Grid + react-automat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3282
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "react-virtual-aggridautomat",
"private": true,
"version": "0.0.0",
"license": "0BSD",
"type": "module",
"sideEffects": false,
"main": "./src/lib/index.ts",
"types": "./src/lib/index.ts",
"exports": {
".": {
"types": "./src/lib/index.ts",
"import": "./src/lib/index.ts",
"default": "./src/lib/index.ts"
},
"./createGridAutomat": {
"types": "./src/lib/createGridAutomat.ts",
"import": "./src/lib/createGridAutomat.ts",
"default": "./src/lib/createGridAutomat.ts"
}
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@types/prismjs": "^1.26.6",
"ag-grid-community": "^36.2.0",
"ag-grid-react": "^36.2.0",
"marked": "^18.0.14",
"prismjs": "^1.30.0",
"react": "^19.3.0",
"react-automat": "file:../automat",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"eslint": "^10.12.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.13.0",
"typescript": "~7.0.2",
"typescript-eslint": "^8.71.0",
"vite": "^8.3.2"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

156
src/App.tsx Normal file
View File

@@ -0,0 +1,156 @@
import { PureComponent } from 'react';
import { DemoGrid } from './demo/DemoGrid';
import { DocViewer } from './demo/DocViewer';
import { CodeViewer } from './demo/CodeViewer';
export type AppView = 'demo' | 'docs' | 'code' | 'split';
interface AppState {
view: AppView;
splitRightTab: 'docs' | 'code';
}
class App extends PureComponent<Record<string, never>, AppState> {
constructor(props: Record<string, never>) {
super(props);
this.state = {
view: 'demo',
splitRightTab: 'docs',
};
}
private handleSetView = (view: AppView): void => {
this.setState({ view });
};
private handleSetSplitRightTab = (tab: 'docs' | 'code'): void => {
this.setState({ splitRightTab: tab });
};
render() {
const { view, splitRightTab } = this.state;
return (
<div className="app-shell">
{/* ── Main Top Navigation Bar ── */}
<header className="app-topbar">
<div className="app-topbar-brand">
<span className="app-brand-icon">⚡</span>
<div className="app-brand-text">
<span className="app-brand-title">react-virtual-aggridautomat</span>
<span className="app-brand-subtitle">Infinite AG Grid + react-automat</span>
</div>
<div className="app-brand-badges">
<span className="badge-chip badge-ag">AG Grid v36</span>
<span className="badge-chip badge-automat">react-automat</span>
<span className="badge-chip badge-pure">PureComponent</span>
</div>
</div>
<nav className="app-nav-tabs" aria-label="Main Navigation">
<button
type="button"
className={`nav-tab-btn ${view === 'demo' ? 'active' : ''}`}
onClick={() => this.handleSetView('demo')}
>
<span className="nav-tab-icon">⚡</span>
<span>Live Demo</span>
</button>
<button
type="button"
className={`nav-tab-btn ${view === 'docs' ? 'active' : ''}`}
onClick={() => this.handleSetView('docs')}
>
<span className="nav-tab-icon">📖</span>
<span>Documentation</span>
</button>
<button
type="button"
className={`nav-tab-btn ${view === 'code' ? 'active' : ''}`}
onClick={() => this.handleSetView('code')}
>
<span className="nav-tab-icon">💻</span>
<span>Demo Code</span>
</button>
<button
type="button"
className={`nav-tab-btn ${view === 'split' ? 'active' : ''}`}
onClick={() => this.handleSetView('split')}
title="View Demo and Documentation / Code side by side"
>
<span className="nav-tab-icon">🪟</span>
<span>Split View</span>
</button>
</nav>
<div className="app-topbar-actions">
<a
href="https://github.com/sebseb7/react-automat"
target="_blank"
rel="noopener noreferrer"
className="app-github-link"
title="GitHub repository"
>
GitHub ↗
</a>
</div>
</header>
{/* ── Content View Area ── */}
<main className="app-viewport">
{view === 'demo' && (
<DemoGrid onNavigateView={this.handleSetView} />
)}
{view === 'docs' && (
<DocViewer />
)}
{view === 'code' && (
<CodeViewer />
)}
{view === 'split' && (
<div className="split-view-container">
<div className="split-pane split-pane-left">
<DemoGrid onNavigateView={this.handleSetView} />
</div>
<div className="split-divider" />
<div className="split-pane split-pane-right">
<div className="split-right-header">
<div className="split-tab-switcher">
<button
type="button"
className={`split-tab-btn ${splitRightTab === 'docs' ? 'active' : ''}`}
onClick={() => this.handleSetSplitRightTab('docs')}
>
📖 Documentation
</button>
<button
type="button"
className={`split-tab-btn ${splitRightTab === 'code' ? 'active' : ''}`}
onClick={() => this.handleSetSplitRightTab('code')}
>
💻 Demo & Core Code
</button>
</div>
</div>
<div className="split-right-content">
{splitRightTab === 'docs' ? <DocViewer /> : <CodeViewer />}
</div>
</div>
</div>
)}
</main>
</div>
);
}
}
export default App;

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

208
src/demo/CodeViewer.tsx Normal file
View File

@@ -0,0 +1,208 @@
import { PureComponent } from 'react';
import Prism from 'prismjs';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-tsx';
import demoGridCode from './DemoGrid.tsx?raw';
import demoAutomatCode from './demoAutomat.ts?raw';
import automatGridCode from '../lib/AutomatGrid.tsx?raw';
import createGridAutomatCode from '../lib/createGridAutomat.ts?raw';
interface SourceFile {
id: string;
name: string;
category: 'demo' | 'lib';
description: string;
content: string;
language: 'tsx' | 'typescript';
}
const FILES: SourceFile[] = [
{
id: 'demo-grid',
name: 'DemoGrid.tsx',
category: 'demo',
description: 'Showcase PureComponent wiring AutomatGrid, columns, and live stats',
content: demoGridCode,
language: 'tsx',
},
{
id: 'demo-automat',
name: 'demoAutomat.ts',
category: 'demo',
description: '10,000 fake items generator, simulated async server fetcher & store factory',
content: demoAutomatCode,
language: 'typescript',
},
{
id: 'lib-grid',
name: 'AutomatGrid.tsx',
category: 'lib',
description: 'Reusable PureComponent wrapper for AG Grid InfiniteRowModel & react-automat',
content: automatGridCode,
language: 'tsx',
},
{
id: 'lib-factory',
name: 'createGridAutomat.ts',
category: 'lib',
description: 'Automat store factory managing scroll revisions, sort, filter, and state',
content: createGridAutomatCode,
language: 'typescript',
},
];
interface CodeViewerProps {
initialFileId?: string;
}
interface CodeViewerState {
activeFileId: string;
copied: boolean;
}
export class CodeViewer extends PureComponent<CodeViewerProps, CodeViewerState> {
private copyTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(props: CodeViewerProps) {
super(props);
this.state = {
activeFileId: props.initialFileId || 'demo-grid',
copied: false,
};
}
componentWillUnmount(): void {
if (this.copyTimeout) {
clearTimeout(this.copyTimeout);
}
}
private handleSelectFile = (fileId: string): void => {
this.setState({ activeFileId: fileId, copied: false });
};
private handleCopy = (): void => {
const activeFile = FILES.find((f) => f.id === this.state.activeFileId);
if (!activeFile) return;
navigator.clipboard.writeText(activeFile.content).then(() => {
this.setState({ copied: true });
if (this.copyTimeout) clearTimeout(this.copyTimeout);
this.copyTimeout = setTimeout(() => {
this.setState({ copied: false });
}, 2000);
});
};
private lineNumbersRef: HTMLDivElement | null = null;
private handleCodeScroll = (e: React.UIEvent<HTMLPreElement>): void => {
if (this.lineNumbersRef) {
this.lineNumbersRef.scrollTop = e.currentTarget.scrollTop;
}
};
render() {
const { activeFileId, copied } = this.state;
const activeFile = FILES.find((f) => f.id === activeFileId) || FILES[0];
// Compute line count
const lines = activeFile.content.split('\n');
const lineCount = lines.length;
// Highlight code using Prism
const grammar = Prism.languages.tsx || Prism.languages.typescript;
const highlightedHtml = Prism.highlight(
activeFile.content,
grammar,
activeFile.language,
);
return (
<div className="code-viewer-container">
{/* Top File Selector Bar */}
<div className="code-viewer-tabs">
<div className="code-tabs-group">
<span className="code-tabs-label">Demo Files:</span>
{FILES.filter((f) => f.category === 'demo').map((f) => (
<button
key={f.id}
type="button"
className={`code-tab-btn ${activeFileId === f.id ? 'active' : ''}`}
onClick={() => this.handleSelectFile(f.id)}
>
<span className="code-tab-icon">📄</span>
<span className="code-tab-name">{f.name}</span>
</button>
))}
</div>
<div className="code-tabs-group">
<span className="code-tabs-label">Library Core:</span>
{FILES.filter((f) => f.category === 'lib').map((f) => (
<button
key={f.id}
type="button"
className={`code-tab-btn ${activeFileId === f.id ? 'active' : ''}`}
onClick={() => this.handleSelectFile(f.id)}
>
<span className="code-tab-icon">⚙</span>
<span className="code-tab-name">{f.name}</span>
</button>
))}
</div>
</div>
{/* Active File Header info */}
<div className="code-file-meta">
<div className="code-file-details">
<span className="code-file-tag">
{activeFile.category === 'demo' ? 'DEMO SOURCE' : 'CORE LIBRARY'}
</span>
<span className="code-file-name">{activeFile.name}</span>
<span className="code-file-desc">{activeFile.description}</span>
</div>
<div className="code-file-actions">
<span className="code-file-lines">{lineCount} lines</span>
<button
type="button"
className={`code-copy-full-btn ${copied ? 'copied' : ''}`}
onClick={this.handleCopy}
>
{copied ? '✓ Copied to Clipboard' : '📋 Copy File'}
</button>
</div>
</div>
{/* Code Content with Line Numbers */}
<div className="code-editor-layout">
{/* Gutter / Line numbers */}
<div
className="code-line-numbers"
ref={(el) => {
this.lineNumbersRef = el;
}}
aria-hidden="true"
>
{lines.map((_, idx) => (
<div key={idx + 1} className="code-line-number">
{idx + 1}
</div>
))}
</div>
{/* Syntax Highlighted Code */}
<pre className="code-syntax-area" onScroll={this.handleCodeScroll}>
<code
className={`language-${activeFile.language}`}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
</pre>
</div>
</div>
);
}
}

517
src/demo/DemoGrid.tsx Normal file
View File

@@ -0,0 +1,517 @@
/**
* DemoGrid.tsx
*
* Demo PureComponent showing AutomatGrid in action.
* Subscribes to demoGridAutomat for status display and
* demonstrates interactive server-side sorting, column filtering,
* floating filter rows, and quick presets.
*/
import { PureComponent } from 'react';
import type { ChangeEvent } from 'react';
import { AutomatGrid } from '../lib/AutomatGrid';
import { demoGridAutomat } from './demoAutomat';
import type { ProductRow } from './demoAutomat';
import type { ColDef } from 'ag-grid-community';
import type { SortModelItem } from '../lib/createGridAutomat';
// ─── Status badge renderer ──────────────────────────────────────
function StatusBadge({ value }: { value: string }) {
const colors: Record<string, { bg: string; fg: string }> = {
active: { bg: '#059669', fg: '#ecfdf5' },
pending: { bg: '#d97706', fg: '#fffbeb' },
discontinued: { bg: '#dc2626', fg: '#fef2f2' },
};
const c = colors[value] ?? { bg: '#4b5563', fg: '#f3f4f6' };
return (
<span
style={{
background: c.bg,
color: c.fg,
padding: '3px 10px',
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
textTransform: 'capitalize',
letterSpacing: 0.3,
display: 'inline-block',
lineHeight: 1.2,
}}
>
{value}
</span>
);
}
// ─── Column definitions with full filter & sort support ──────────
const COLUMN_DEFS: ColDef<ProductRow>[] = [
{
headerName: '#',
field: 'id',
maxWidth: 90,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: true,
cellStyle: { fontVariantNumeric: 'tabular-nums' },
},
{
headerName: 'SKU',
field: 'sku',
maxWidth: 140,
sortable: true,
filter: 'agTextColumnFilter',
floatingFilter: true,
cellStyle: { fontVariantNumeric: 'tabular-nums' },
},
{
headerName: 'Product Name',
field: 'name',
minWidth: 220,
flex: 2,
sortable: true,
filter: 'agTextColumnFilter',
floatingFilter: true,
},
{
headerName: 'Category',
field: 'category',
minWidth: 150,
sortable: true,
filter: 'agTextColumnFilter',
floatingFilter: true,
},
{
headerName: 'Price',
field: 'price',
maxWidth: 130,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: true,
valueFormatter: (p) => (p.value != null ? `€${Number(p.value).toFixed(2)}` : ''),
cellStyle: { textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
},
{
headerName: 'Stock',
field: 'stock',
maxWidth: 110,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: true,
cellStyle: { textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
},
{
headerName: 'Supplier',
field: 'supplier',
minWidth: 160,
sortable: true,
filter: 'agTextColumnFilter',
floatingFilter: true,
},
{
headerName: 'Rating',
field: 'rating',
maxWidth: 100,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: true,
valueFormatter: (p) => (p.value != null ? `${Number(p.value).toFixed(1)} ★` : ''),
cellStyle: { textAlign: 'center' },
},
{
headerName: 'Status',
field: 'status',
maxWidth: 140,
sortable: true,
filter: 'agTextColumnFilter',
floatingFilter: true,
cellRenderer: StatusBadge,
},
{
headerName: 'Updated',
field: 'lastUpdated',
maxWidth: 130,
sortable: true,
filter: 'agTextColumnFilter',
floatingFilter: true,
},
];
// ─── Component Props & State ─────────────────────────────────────
export interface DemoGridProps {
onNavigateView?: (view: 'demo' | 'docs' | 'code' | 'split') => void;
}
interface DemoGridState {
loading: boolean;
ready: boolean;
totalRows: number;
error: string | null;
sortModel: SortModelItem[];
filterModel: Record<string, unknown>;
revision: number;
searchValue: string;
}
// ─── Demo Component ──────────────────────────────────────────────
export class DemoGrid extends PureComponent<DemoGridProps, DemoGridState> {
private unsub: (() => void) | null = null;
private searchTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(props: DemoGridProps) {
super(props);
const s = demoGridAutomat.state;
this.state = {
loading: s.loading,
ready: s.ready,
totalRows: s.totalRows,
error: s.error,
sortModel: s.sortModel,
filterModel: s.filterModel,
revision: s.revision,
searchValue: '',
};
}
componentDidMount(): void {
this.unsub = demoGridAutomat.subscribe(
this as unknown as import('react').PureComponent,
(s) => ({
loading: s.loading,
ready: s.ready,
totalRows: s.totalRows,
error: s.error,
sortModel: s.sortModel,
filterModel: s.filterModel,
revision: s.revision,
}),
);
}
componentWillUnmount(): void {
this.unsub?.();
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
// ── Actions ───────────────────────────────────────────────────
private handleReload = (): void => {
demoGridAutomat.actions.reload();
};
private handleSetSort = (colId: string, dir: 'asc' | 'desc'): void => {
demoGridAutomat.actions.setSort([{ colId, sort: dir }]);
};
private handleClearSort = (): void => {
demoGridAutomat.actions.setSort([]);
};
private handleSetFilterPreset = (filterModel: Record<string, unknown>): void => {
this.setState({ searchValue: '' });
demoGridAutomat.actions.setFilter(filterModel);
};
private handleClearFilter = (): void => {
this.setState({ searchValue: '' });
demoGridAutomat.actions.setFilter({});
};
private handleSearchInput = (e: ChangeEvent<HTMLInputElement>): void => {
const val = e.target.value;
this.setState({ searchValue: val });
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchTimeout = setTimeout(() => {
const current = { ...demoGridAutomat.state.filterModel };
if (!val.trim()) {
delete current.name;
} else {
current.name = {
filterType: 'text',
type: 'contains',
filter: val.trim(),
};
}
demoGridAutomat.actions.setFilter(current);
}, 200);
};
render() {
const { loading, totalRows, error, sortModel, filterModel, revision, searchValue } = this.state;
const hasFilters = Object.keys(filterModel).length > 0;
const hasSort = sortModel.length > 0;
// Formatting active sort description
const currentSort = sortModel[0];
const sortSummary = currentSort
? `${currentSort.colId.toUpperCase()} (${currentSort.sort === 'asc' ? '↑ Asc' : '↓ Desc'})`
: 'None (Natural)';
// Formatting active filter keys
const filterKeys = Object.keys(filterModel);
const filterSummary = filterKeys.length > 0
? filterKeys.map((k) => `${k}`).join(', ')
: 'None (All Rows)';
return (
<div className="demo-container">
{/* ── Top Header bar ── */}
<header className="demo-header">
<div className="demo-header-left">
<h1 className="demo-title">
<span className="demo-title-icon">⚡</span>
AutomatGrid Demo
</h1>
<span className="demo-subtitle">
10,000 rows · Infinite scroll · Server sort & filter · Automat-driven
</span>
</div>
<div className="demo-header-right">
<div className="demo-stats">
{totalRows >= 0 && (
<span className="demo-stat">
{totalRows.toLocaleString()} rows
</span>
)}
{error && (
<span className="demo-stat demo-stat--error">
⚠ {error}
</span>
)}
</div>
{this.props.onNavigateView && (
<div className="demo-header-actions">
<button
type="button"
className="demo-pill-btn"
onClick={() => this.props.onNavigateView?.('docs')}
title="View full README documentation"
>
📖 Docs
</button>
<button
type="button"
className="demo-pill-btn"
onClick={() => this.props.onNavigateView?.('code')}
title="View demo & library source code"
>
💻 Code
</button>
</div>
)}
<button
className="demo-reload-btn"
onClick={this.handleReload}
disabled={loading}
title="Reload data (bumps automat revision)"
>
↻ Reload
</button>
</div>
</header>
{/* ── Sort & Filter Interactive Control Showcase ── */}
<section className="demo-toolbar" aria-label="Sort and Filter Controls">
{/* Row 1: Filters */}
<div className="demo-toolbar-row">
<div className="demo-search-box">
<span className="demo-search-icon">🔍</span>
<input
type="text"
className="demo-search-input"
placeholder="Filter by Product Name..."
value={searchValue}
onChange={this.handleSearchInput}
/>
{searchValue && (
<button
type="button"
className="demo-search-clear"
onClick={() => {
this.setState({ searchValue: '' });
const current = { ...demoGridAutomat.state.filterModel };
delete current.name;
demoGridAutomat.actions.setFilter(current);
}}
title="Clear name filter"
>
✕
</button>
)}
</div>
<div className="demo-preset-group">
<span className="demo-group-label">Filter Presets:</span>
<button
type="button"
className={`demo-filter-btn ${!hasFilters ? 'active' : ''}`}
onClick={this.handleClearFilter}
>
All (10k)
</button>
<button
type="button"
className={`demo-filter-btn ${filterModel.status ? 'active' : ''}`}
onClick={() => this.handleSetFilterPreset({ status: { filterType: 'text', type: 'equals', filter: 'active' } })}
>
🟢 Active
</button>
<button
type="button"
className={`demo-filter-btn`}
onClick={() => this.handleSetFilterPreset({ status: { filterType: 'text', type: 'equals', filter: 'pending' } })}
>
🟡 Pending
</button>
<button
type="button"
className={`demo-filter-btn`}
onClick={() => this.handleSetFilterPreset({ status: { filterType: 'text', type: 'equals', filter: 'discontinued' } })}
>
🔴 Discontinued
</button>
<button
type="button"
className={`demo-filter-btn ${filterModel.category ? 'active' : ''}`}
onClick={() => this.handleSetFilterPreset({ category: { filterType: 'text', type: 'contains', filter: 'Electronics' } })}
>
⚡ Electronics
</button>
<button
type="button"
className={`demo-filter-btn ${filterModel.rating ? 'active' : ''}`}
onClick={() => this.handleSetFilterPreset({ rating: { filterType: 'number', type: 'greaterThanOrEqual', filter: 4 } })}
>
★ Rating ≥ 4.0
</button>
<button
type="button"
className={`demo-filter-btn ${filterModel.stock ? 'active' : ''}`}
onClick={() => this.handleSetFilterPreset({ stock: { filterType: 'number', type: 'lessThan', filter: 500 } })}
>
📉 Low Stock (&lt;500)
</button>
<button
type="button"
className={`demo-filter-btn ${filterModel.price ? 'active' : ''}`}
onClick={() => this.handleSetFilterPreset({ price: { filterType: 'number', type: 'lessThan', filter: 50 } })}
>
🏷 Price &lt; €50
</button>
</div>
</div>
{/* Row 2: Sorts & Live State Indicator */}
<div className="demo-toolbar-row demo-toolbar-row--sub">
<div className="demo-preset-group">
<span className="demo-group-label">Quick Sort:</span>
<button
type="button"
className={`demo-sort-btn ${currentSort?.colId === 'price' && currentSort?.sort === 'asc' ? 'active' : ''}`}
onClick={() => this.handleSetSort('price', 'asc')}
>
Price: Low → High
</button>
<button
type="button"
className={`demo-sort-btn ${currentSort?.colId === 'price' && currentSort?.sort === 'desc' ? 'active' : ''}`}
onClick={() => this.handleSetSort('price', 'desc')}
>
Price: High → Low
</button>
<button
type="button"
className={`demo-sort-btn ${currentSort?.colId === 'rating' && currentSort?.sort === 'desc' ? 'active' : ''}`}
onClick={() => this.handleSetSort('rating', 'desc')}
>
Rating: High → Low
</button>
<button
type="button"
className={`demo-sort-btn ${currentSort?.colId === 'name' && currentSort?.sort === 'asc' ? 'active' : ''}`}
onClick={() => this.handleSetSort('name', 'asc')}
>
Name: A → Z
</button>
{hasSort && (
<button
type="button"
className="demo-clear-btn"
onClick={this.handleClearSort}
>
✕ Clear Sort
</button>
)}
</div>
{/* Live Automat State Pills */}
<div className="demo-automat-state-bar">
<span className="state-badge state-badge--sort" title="Active Sort Model in demoGridAutomat">
Sort: <strong>{sortSummary}</strong>
</span>
<span className="state-badge state-badge--filter" title="Active Filter Model in demoGridAutomat">
Filter: <strong>{filterSummary}</strong>
</span>
<span className="state-badge state-badge--rev" title="Current store revision count">
Rev: <strong>#{revision}</strong>
</span>
{(hasFilters || hasSort) && (
<button
type="button"
className="demo-reset-all-btn"
onClick={() => {
this.handleClearFilter();
this.handleClearSort();
}}
title="Reset all filters and sorting"
>
Reset All
</button>
)}
</div>
</div>
</section>
{/* ── Virtual Grid ── */}
<div className="demo-grid-wrapper">
<AutomatGrid<ProductRow>
automat={demoGridAutomat}
columnDefs={COLUMN_DEFS}
defaultColDef={{
sortable: true,
resizable: true,
filter: true,
floatingFilter: true,
}}
rowIdField="id"
cacheBlockSize={100}
maxBlocksInCache={100}
infiniteInitialRowCount={10000}
blockLoadDebounceMillis={80}
height="100%"
width="100%"
/>
</div>
{/* ── Footer ── */}
<footer className="demo-footer">
<span>
<code>react-automat</code> + <code>ag-grid-react</code> · Virtualized PureComponent with Server-Side Sort & Filter
</span>
</footer>
</div>
);
}
}

195
src/demo/DocViewer.tsx Normal file
View File

@@ -0,0 +1,195 @@
import { PureComponent } from 'react';
import { marked } from 'marked';
import Prism from 'prismjs';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-tsx';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-markdown';
import readmeRaw from '../../README.md?raw';
interface TocItem {
id: string;
text: string;
depth: number;
}
interface DocViewerState {
html: string;
toc: TocItem[];
activeHeadingId: string | null;
copiedIndex: number | null;
}
export class DocViewer extends PureComponent<Record<string, never>, DocViewerState> {
private contentRef: HTMLElement | null = null;
constructor(props: Record<string, never>) {
super(props);
const { html, toc } = this.parseMarkdown(readmeRaw);
this.state = {
html,
toc,
activeHeadingId: toc[0]?.id ?? null,
copiedIndex: null,
};
}
private parseMarkdown(markdown: string): { html: string; toc: TocItem[] } {
const toc: TocItem[] = [];
marked.use({
gfm: true,
breaks: false,
renderer: {
heading({ text, depth }) {
// Generate URL-friendly slug
const plainText = text.replace(/<[^>]+>/g, '').trim();
const id = plainText
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
if (depth === 2 || depth === 3) {
toc.push({ id, text: plainText, depth });
}
return `<h${depth} id="${id}" class="doc-heading doc-heading-${depth}"><a href="#${id}" class="heading-anchor">#</a>${text}</h${depth}>`;
},
code({ text, lang }) {
const validLang = lang && Prism.languages[lang] ? lang : 'typescript';
const grammar = Prism.languages[validLang] || Prism.languages.typescript;
let highlighted: string;
try {
highlighted = Prism.highlight(text, grammar, validLang);
} catch {
highlighted = text;
}
const encodedRaw = encodeURIComponent(text);
return `
<div class="doc-code-block" data-raw="${encodedRaw}">
<div class="doc-code-header">
<span class="doc-code-lang">${lang || 'code'}</span>
<button class="doc-code-copy" type="button" aria-label="Copy snippet">Copy</button>
</div>
<pre class="language-${lang}"><code class="language-${lang}">${highlighted}</code></pre>
</div>
`;
},
table({ header, rows }) {
return `<div class="doc-table-wrapper"><table class="doc-table"><thead>${header}</thead><tbody>${rows}</tbody></table></div>`;
},
},
});
const html = marked.parse(markdown) as string;
return { html, toc };
}
componentDidMount(): void {
if (this.contentRef) {
this.contentRef.addEventListener('click', this.handleContentClick);
}
window.addEventListener('scroll', this.handleScroll, true);
}
componentWillUnmount(): void {
if (this.contentRef) {
this.contentRef.removeEventListener('click', this.handleContentClick);
}
window.removeEventListener('scroll', this.handleScroll, true);
}
private handleContentClick = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
if (target?.classList.contains('doc-code-copy')) {
const wrapper = target.closest('.doc-code-block');
if (wrapper) {
const raw = decodeURIComponent(wrapper.getAttribute('data-raw') || '');
navigator.clipboard.writeText(raw).then(() => {
const original = target.textContent;
target.textContent = 'Copied!';
target.classList.add('copied');
setTimeout(() => {
target.textContent = original;
target.classList.remove('copied');
}, 1800);
});
}
}
};
private handleScroll = (): void => {
if (!this.contentRef) return;
const headings = this.contentRef.querySelectorAll('.doc-heading');
const scrollPos = this.contentRef.scrollTop + 100;
let currentId: string | null = null;
headings.forEach((h) => {
const el = h as HTMLElement;
if (el.offsetTop <= scrollPos) {
currentId = el.id;
}
});
if (currentId && currentId !== this.state.activeHeadingId) {
this.setState({ activeHeadingId: currentId });
}
};
private scrollToHeading = (id: string): void => {
if (!this.contentRef) return;
const target = this.contentRef.querySelector(`#${id}`) as HTMLElement;
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
this.setState({ activeHeadingId: id });
}
};
render() {
const { html, toc, activeHeadingId } = this.state;
return (
<div className="doc-layout">
{/* Table of contents sidebar */}
<aside className="doc-sidebar">
<div className="doc-sidebar-header">
<span className="doc-sidebar-icon">📑</span>
<span>Documentation</span>
</div>
<nav className="doc-toc">
{toc.map((item) => (
<button
key={item.id}
type="button"
className={`doc-toc-item doc-toc-depth-${item.depth} ${
activeHeadingId === item.id ? 'active' : ''
}`}
onClick={() => this.scrollToHeading(item.id)}
>
{item.text}
</button>
))}
</nav>
</aside>
{/* Main rendered documentation */}
<main
className="doc-content-container"
ref={(el) => {
this.contentRef = el;
}}
onScroll={this.handleScroll}
>
<div
className="doc-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
</main>
</div>
);
}
}

222
src/demo/demoAutomat.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* demoAutomat.ts
*
* Demo data generator & grid automat for the showcase app.
* Generates 10,000 fake product rows and serves them through a simulated
* async fetcher with configurable latency — demonstrating how AutomatGrid
* virtualizes a large dataset.
*/
import { createGridAutomat } from '../lib/createGridAutomat';
import type { GridFetchParams, GridFetchResult } from '../lib/createGridAutomat';
// ─── Demo row type ───────────────────────────────────────────────
export interface ProductRow {
id: number;
name: string;
sku: string;
category: string;
price: number;
stock: number;
supplier: string;
rating: number;
status: 'active' | 'discontinued' | 'pending';
lastUpdated: string;
}
// ─── Fake data generation ────────────────────────────────────────
const CATEGORIES = [
'Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books',
'Toys', 'Automotive', 'Health', 'Food & Beverage', 'Office',
];
const SUPPLIERS = [
'Globex Corp', 'Initech', 'Umbrella Co', 'Stark Industries',
'Wayne Enterprises', 'Cyberdyne', 'Acme Corp', 'Soylent',
'Weyland-Yutani', 'Tyrell Corp',
];
const ADJECTIVES = [
'Ultra', 'Premium', 'Elite', 'Pro', 'Max', 'Nano', 'Hyper',
'Mega', 'Smart', 'Eco', 'Turbo', 'Quantum', 'Apex', 'Prime',
];
const PRODUCTS = [
'Widget', 'Gadget', 'Module', 'Sensor', 'Drive', 'Board',
'Panel', 'Switch', 'Cable', 'Adapter', 'Battery', 'Display',
'Controller', 'Router', 'Hub', 'Filter', 'Valve', 'Motor',
];
const STATUSES: ProductRow['status'][] = ['active', 'discontinued', 'pending'];
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 16807 + 0) % 2147483647;
return s / 2147483647;
};
}
function generateProducts(count: number): ProductRow[] {
const rng = seededRandom(42);
const rows: ProductRow[] = [];
for (let i = 0; i < count; i++) {
const adj = ADJECTIVES[Math.floor(rng() * ADJECTIVES.length)];
const prod = PRODUCTS[Math.floor(rng() * PRODUCTS.length)];
const cat = CATEGORIES[Math.floor(rng() * CATEGORIES.length)];
const sup = SUPPLIERS[Math.floor(rng() * SUPPLIERS.length)];
const status = STATUSES[Math.floor(rng() * STATUSES.length)];
const year = 2024 + Math.floor(rng() * 3);
const month = 1 + Math.floor(rng() * 12);
const day = 1 + Math.floor(rng() * 28);
rows.push({
id: i + 1,
name: `${adj} ${prod} ${i + 1}`,
sku: `SKU-${String(i + 1).padStart(6, '0')}`,
category: cat,
price: Math.round((rng() * 999 + 1) * 100) / 100,
stock: Math.floor(rng() * 5000),
supplier: sup,
rating: Math.round(rng() * 50) / 10,
status,
lastUpdated: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
});
}
return rows;
}
// Pre-generate the dataset once at module load
const ALL_PRODUCTS = generateProducts(10_000);
// ─── Simulated fetcher ───────────────────────────────────────────
/** Helper to evaluate a single filter condition on a row value. */
function matchesCondition(rawVal: unknown, filter: {
filter?: string | number;
filterTo?: number;
type?: string;
filterType?: string;
}): boolean {
if (filter.type === 'blank') {
return rawVal == null || rawVal === '';
}
if (filter.type === 'notBlank') {
return rawVal != null && rawVal !== '';
}
if (filter.filter == null && filter.filterTo == null) {
return true;
}
// Number comparison if filter is numeric or value is numeric
if (typeof filter.filter === 'number' || filter.filterType === 'number' || typeof rawVal === 'number') {
const val = Number(rawVal);
const target = Number(filter.filter);
if (isNaN(val)) return false;
switch (filter.type) {
case 'equals': return val === target;
case 'notEqual': return val !== target;
case 'greaterThan': return val > target;
case 'greaterThanOrEqual': return val >= target;
case 'lessThan': return val < target;
case 'lessThanOrEqual': return val <= target;
case 'inRange': {
const to = Number(filter.filterTo ?? target);
return val >= target && val <= to;
}
default: return val === target;
}
}
// String comparison
const strVal = String(rawVal ?? '').toLowerCase();
const filterText = String(filter.filter).toLowerCase().trim();
switch (filter.type) {
case 'equals': return strVal === filterText;
case 'notEqual': return strVal !== filterText;
case 'startsWith': return strVal.startsWith(filterText);
case 'endsWith': return strVal.endsWith(filterText);
case 'contains':
default:
return strVal.includes(filterText);
}
}
/** Simulated server-side sort & filter + paginated fetch with artificial latency. */
async function fetchProducts(params: GridFetchParams): Promise<GridFetchResult<ProductRow>> {
// Simulate network latency (40-100ms)
await new Promise((r) => setTimeout(r, 40 + Math.random() * 60));
let data = [...ALL_PRODUCTS];
// 1. Server-side sorting
if (params.sortModel.length > 0) {
const sorts = params.sortModel;
data.sort((a, b) => {
for (const sort of sorts) {
const dir = sort.sort === 'asc' ? 1 : -1;
const aVal = a[sort.colId as keyof ProductRow];
const bVal = b[sort.colId as keyof ProductRow];
if (typeof aVal === 'number' && typeof bVal === 'number') {
if (aVal !== bVal) return (aVal - bVal) * dir;
} else {
const aStr = String(aVal ?? '');
const bStr = String(bVal ?? '');
const cmp = aStr.localeCompare(bStr, undefined, { numeric: true });
if (cmp !== 0) return cmp * dir;
}
}
return 0;
});
}
// 2. Server-side filtering
const filterKeys = Object.keys(params.filterModel);
if (filterKeys.length > 0) {
for (const key of filterKeys) {
const model = params.filterModel[key] as {
operator?: 'AND' | 'OR';
conditions?: Array<{ filter?: string | number; filterTo?: number; type?: string; filterType?: string }>;
filter?: string | number;
filterTo?: number;
type?: string;
filterType?: string;
};
if (model.conditions && model.conditions.length > 0) {
data = data.filter((row) => {
const rawVal = row[key as keyof ProductRow];
if (model.operator === 'OR') {
return model.conditions!.some((cond) => matchesCondition(rawVal, cond));
}
return model.conditions!.every((cond) => matchesCondition(rawVal, cond));
});
} else if (model.filter != null || model.type === 'blank' || model.type === 'notBlank') {
data = data.filter((row) => {
const rawVal = row[key as keyof ProductRow];
return matchesCondition(rawVal, model);
});
}
}
}
const rows = data.slice(params.startRow, params.endRow);
const lastRow = data.length;
return { rows, lastRow };
}
// ─── Grid automat instance ───────────────────────────────────────
export const demoGridAutomat = createGridAutomat<ProductRow>({
fetchRows: fetchProducts,
name: 'demoGrid',
blockSize: 100,
});

429
src/index.css Normal file
View File

@@ -0,0 +1,429 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
font-family: system-ui, sans-serif;
}
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid #ccc;
gap: 12px;
}
.app-topbar-brand {
display: flex;
align-items: center;
gap: 8px;
}
.app-nav-tabs, .app-split-tabs, .code-viewer-tabs {
display: flex;
gap: 6px;
}
.nav-tab-btn, .split-tab-btn, .code-tab-btn, .demo-filter-btn, .demo-sort-btn, .demo-reload-btn, .demo-reset-all-btn {
padding: 4px 8px;
cursor: pointer;
}
.nav-tab-btn.active, .split-tab-btn.active, .code-tab-btn.active, .demo-filter-btn.active, .demo-sort-btn.active {
font-weight: bold;
}
.app-viewport, .split-right-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.split-view-container {
display: flex;
height: 100%;
width: 100%;
flex: 1;
min-height: 0;
}
.split-pane {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.split-pane-right {
border-left: 1px solid #ccc;
}
.split-right-header {
padding: 6px 12px;
border-bottom: 1px solid #ccc;
display: flex;
}
.demo-container, .demo-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 6px;
overflow: hidden;
}
.demo-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
flex-wrap: nowrap;
}
.demo-header-left, .demo-header-right, .demo-stats, .demo-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.demo-toolbar-row, .demo-automat-state-bar, .code-file-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.demo-toolbar {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
border: 1px solid #ccc;
flex-shrink: 0;
}
.demo-search-box {
display: flex;
align-items: center;
gap: 4px;
}
.demo-preset-group {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.demo-grid-wrapper {
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.doc-layout {
flex: 1;
min-height: 0;
display: flex;
height: 100%;
}
.doc-sidebar {
width: 220px;
overflow-y: auto;
border-right: 1px solid #ccc;
padding: 12px;
}
.doc-toc {
display: flex;
flex-direction: column;
gap: 4px;
}
.doc-toc-item {
text-align: left;
cursor: pointer;
background: none;
border: none;
}
.doc-toc-item.active {
font-weight: bold;
}
.doc-content-container, .doc-content-wrapper {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 32px;
}
.doc-content {
max-width: 900px;
line-height: 1.6;
}
.doc-content h1 {
font-size: 24px;
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.doc-content h2 {
font-size: 18px;
margin: 24px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px solid #eee;
}
.doc-content h3 {
font-size: 15px;
margin: 16px 0 8px 0;
}
.doc-content p {
margin: 0 0 12px 0;
}
.doc-content ul, .doc-content ol {
margin: 0 0 12px 20px;
}
.doc-content li {
margin-bottom: 4px;
}
.doc-content code {
font-family: monospace;
font-size: 12px;
background: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
}
.doc-code-block {
margin: 12px 0;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
.doc-code-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #f4f4f4;
padding: 4px 10px;
border-bottom: 1px solid #ddd;
font-size: 11px;
}
.doc-code-copy {
padding: 2px 6px;
font-size: 11px;
cursor: pointer;
}
.doc-code-block pre {
margin: 0;
padding: 10px;
overflow-x: auto;
background: #fafafa;
font-size: 12px;
line-height: 1.4;
}
.doc-table-wrapper {
overflow-x: auto;
margin: 12px 0;
}
.doc-table {
width: 100%;
border-collapse: collapse;
}
.doc-table th, .doc-table td {
border: 1px solid #ddd;
padding: 6px 10px;
text-align: left;
font-size: 13px;
}
.doc-table th {
background: #f5f5f5;
}
.code-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
}
.code-viewer-tabs {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 12px;
border-bottom: 1px solid #ccc;
background: #f8f9fa;
flex-shrink: 0;
flex-wrap: wrap;
}
.code-tabs-group {
display: flex;
align-items: center;
gap: 6px;
}
.code-tabs-label {
font-size: 11px;
color: #666;
font-weight: 600;
text-transform: uppercase;
}
.code-tab-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 12px;
background: #fff;
border: 1px solid #ccc;
border-radius: 3px;
cursor: pointer;
}
.code-tab-btn.active {
background: #0066cc;
color: #fff;
border-color: #0066cc;
font-weight: bold;
}
.code-file-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: #fff;
border-bottom: 1px solid #ccc;
font-size: 12px;
flex-shrink: 0;
}
.code-file-details {
display: flex;
align-items: center;
gap: 8px;
}
.code-file-tag {
font-size: 10px;
padding: 1px 5px;
background: #eee;
border-radius: 2px;
font-weight: 600;
}
.code-file-name {
font-weight: 600;
font-family: monospace;
}
.code-file-desc {
color: #666;
}
.code-file-actions {
display: flex;
align-items: center;
gap: 8px;
}
.code-file-lines {
color: #888;
font-size: 12px;
}
.code-copy-full-btn {
padding: 3px 8px;
font-size: 12px;
cursor: pointer;
}
.code-editor-layout {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
background: #fdfdfd;
}
.code-line-numbers {
padding: 10px 8px;
border-right: 1px solid #e0e0e0;
background: #f7f7f7;
user-select: none;
text-align: right;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
color: #999;
overflow: hidden;
flex-shrink: 0;
min-width: 45px;
}
.code-line-number {
line-height: 1.5;
}
.code-syntax-area, .code-pre-container {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 10px 12px;
margin: 0;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
background: transparent;
white-space: pre;
}

393
src/lib/AutomatGrid.tsx Normal file
View File

@@ -0,0 +1,393 @@
/**
* AutomatGrid.tsx
*
* Opinionated, fully-virtualized AG Grid component powered by react-automat.
*
* This is a React PureComponent that:
* - Subscribes to a GridAutomat store for reactive state (loading, revision, sort, filter)
* - Wires the automat's fetcher into AG Grid's infinite-scroll IDatasource
* - Automatically resets the datasource when the automat's revision bumps
* - Exposes the AG Grid API ref via `onGridApiReady` callback prop
*
* Usage:
* import { AutomatGrid } from 'react-virtual-aggridautomat';
* import { createGridAutomat } from 'react-virtual-aggridautomat';
*
* const gridAutomat = createGridAutomat({ fetchRows: myFetcher });
*
* class MyView extends PureComponent {
* render() {
* return <AutomatGrid automat={gridAutomat} columnDefs={cols} />;
* }
* }
*/
import { PureComponent, createRef } from 'react';
import type { RefObject } from 'react';
import {
type ColDef,
type GridApi,
type GridReadyEvent,
type IDatasource,
type IGetRowsParams,
type SortChangedEvent,
type FilterChangedEvent,
type GetRowIdParams,
type Module,
InfiniteRowModelModule,
TextFilterModule,
NumberFilterModule,
CustomFilterModule,
DateFilterModule,
CellStyleModule,
ColumnApiModule,
ColumnAutoSizeModule,
} from 'ag-grid-community';
import { AgGridProvider, AgGridReact } from 'ag-grid-react';
import type { Automat } from 'react-automat';
import type { GridAutomatState, GridAutomatActions, GridFetchParams, SortModelItem } from './createGridAutomat';
// ─── Props ───────────────────────────────────────────────────────
export interface AutomatGridProps<TRow = Record<string, unknown>> {
/** The GridAutomat store instance created by createGridAutomat(). */
automat: Automat<GridAutomatState<TRow>, GridAutomatActions>;
/** AG Grid column definitions. */
columnDefs: ColDef<TRow>[];
/** Default column definition applied to all columns. */
defaultColDef?: ColDef<TRow>;
/** Row ID field name for stable infinite-scroll selection. Defaults to 'id'. */
rowIdField?: string;
/** Cache block size override (defaults to automat's _blockSize). */
cacheBlockSize?: number;
/** Number of rows rendered outside the visible area. Default 0 (optimal for virtualization). */
rowBuffer?: number;
/** Initial row count hint for the scrollbar. Default 200. */
infiniteInitialRowCount?: number;
/** Max concurrent datasource requests. Default 2. */
maxConcurrentDatasourceRequests?: number;
/** Max blocks to keep in the cache. Default 10. */
maxBlocksInCache?: number;
/** Debounce (ms) before loading blocks on scroll. Default 100. */
blockLoadDebounceMillis?: number;
/** CSS height for the grid container. Default '100%'. */
height?: string | number;
/** CSS width for the grid container. Default '100%'. */
width?: string | number;
/** Additional AG Grid theme CSS class. Defaults to 'ag-theme-alpine ag-theme-alpine-dark'. */
theme?: string;
/** Optional AG Grid modules to register. Defaults to InfiniteRowModelModule + Text/Number/Custom/Date filters. */
modules?: Module[];
/** Called once when the AG Grid API is available. */
onGridApiReady?: (api: GridApi<TRow>) => void;
/** Row height in pixels. Default undefined (uses AG Grid default of 42). */
rowHeight?: number;
/** Header height in pixels. */
headerHeight?: number;
/** Additional AG Grid props passed through. */
gridOptions?: Record<string, unknown>;
/** Whether to suppress horizontal scrollbar. */
suppressHorizontalScroll?: boolean;
/** Whether to animate rows on sort change. */
animateRows?: boolean;
/** Row selection mode. */
rowSelection?: 'single' | 'multiple';
/** Additional class for the grid container div. */
className?: string;
/** Inline style for the grid container div. */
style?: React.CSSProperties;
/** Loading overlay component or text. */
loadingOverlayText?: string;
/** No rows overlay text. */
noRowsOverlayText?: string;
}
// ─── Internal state (from automat subscription) ──────────────────
interface AutomatGridState {
loading: boolean;
ready: boolean;
revision: number;
error: string | null;
totalRows: number;
sortModel: SortModelItem[];
filterModel: Record<string, unknown>;
}
// ─── Modules ─────────────────────────────────────────────────────
const DEFAULT_GRID_MODULES: Module[] = [
InfiniteRowModelModule,
TextFilterModule,
NumberFilterModule,
CustomFilterModule,
DateFilterModule,
CellStyleModule,
ColumnApiModule,
ColumnAutoSizeModule,
];
// ─── Component ───────────────────────────────────────────────────
export class AutomatGrid<TRow = Record<string, unknown>> extends PureComponent<
AutomatGridProps<TRow>,
AutomatGridState
> {
private unsub: (() => void) | null = null;
private gridApiRef: RefObject<GridApi<TRow> | null> = createRef<GridApi<TRow>>();
private isUpdatingFromGrid = false;
constructor(props: AutomatGridProps<TRow>) {
super(props);
// Sync read from automat in constructor — zero blank flash
const s = props.automat.state;
this.state = {
loading: s.loading,
ready: s.ready,
revision: s.revision,
error: s.error,
totalRows: s.totalRows,
sortModel: s.sortModel,
filterModel: s.filterModel,
};
}
componentDidMount(): void {
// Subscribe with a selector that extracts fields we react to
this.unsub = this.props.automat.subscribe(
this as unknown as import('react').PureComponent,
(state: GridAutomatState<TRow>) => ({
loading: state.loading,
ready: state.ready,
revision: state.revision,
error: state.error,
totalRows: state.totalRows,
sortModel: state.sortModel,
filterModel: state.filterModel,
}),
);
}
componentDidUpdate(_prevProps: AutomatGridProps<TRow>, prevState: AutomatGridState): void {
const api = this.gridApiRef.current;
// When revision changes, reset the AG Grid datasource to force a refetch
if (this.state.revision !== prevState.revision) {
if (api && !this.isUpdatingFromGrid) {
// Sync external sort model if changed outside grid
if (this.state.sortModel !== prevState.sortModel) {
const currentCols = api.getColumnState();
const targetCols = currentCols.map((c) => {
const match = this.state.sortModel.find((s) => s.colId === c.colId);
return {
colId: c.colId,
sort: match ? match.sort : null,
};
});
api.applyColumnState({ state: targetCols });
}
// Sync external filter model if changed outside grid
if (this.state.filterModel !== prevState.filterModel) {
api.setFilterModel(this.state.filterModel);
}
}
this.applyDatasource();
}
}
componentWillUnmount(): void {
this.unsub?.();
this.unsub = null;
}
// ─── AG Grid datasource wiring ──────────────────────────────
private buildDatasource(): IDatasource {
const { automat } = this.props;
const fetcher = automat.state._fetcher;
const actions = automat.actions;
return {
getRows: (params: IGetRowsParams) => {
const fetchParams: GridFetchParams = {
startRow: params.startRow,
endRow: params.endRow,
sortModel: params.sortModel?.length ? params.sortModel : automat.state.sortModel,
filterModel: Object.keys(params.filterModel ?? {}).length ? params.filterModel : automat.state.filterModel,
};
actions._fetchStarted();
fetcher(fetchParams)
.then((result) => {
const lastRow =
result.lastRow != null && result.lastRow >= 0
? result.lastRow
: undefined;
params.successCallback(result.rows, lastRow);
actions._fetchCompleted(lastRow ?? -1);
})
.catch((err: Error) => {
params.failCallback();
actions._fetchFailed(err?.message ?? 'Fetch failed');
});
},
};
}
private applyDatasource(): void {
const api = this.gridApiRef.current;
if (api) {
api.setGridOption('datasource', this.buildDatasource());
}
}
private handleGridReady = (event: GridReadyEvent<TRow>): void => {
(this.gridApiRef as { current: GridApi<TRow> | null }).current = event.api;
this.applyDatasource();
this.props.onGridApiReady?.(event.api);
};
private handleSortChanged = (event: SortChangedEvent<TRow>): void => {
if (this.isUpdatingFromGrid) return;
const sortModel = event.api.getColumnState()
.filter((c) => c.sort != null)
.map((c) => ({ colId: c.colId, sort: c.sort! }));
this.isUpdatingFromGrid = true;
try {
this.props.automat.actions.setSort(sortModel);
} finally {
this.isUpdatingFromGrid = false;
}
};
private handleFilterChanged = (event: FilterChangedEvent<TRow>): void => {
if (this.isUpdatingFromGrid) return;
const filterModel = event.api.getFilterModel();
this.isUpdatingFromGrid = true;
try {
this.props.automat.actions.setFilter(filterModel as Record<string, unknown>);
} finally {
this.isUpdatingFromGrid = false;
}
};
private getRowId = (params: GetRowIdParams<TRow>): string => {
const field = this.props.rowIdField ?? 'id';
const data = params.data as Record<string, unknown>;
return data[field] != null ? String(data[field]) : String(params.level);
};
// ─── Render ──────────────────────────────────────────────────
render() {
const {
columnDefs,
defaultColDef,
cacheBlockSize,
rowBuffer = 0,
infiniteInitialRowCount = 200,
maxConcurrentDatasourceRequests = 2,
maxBlocksInCache = 10,
blockLoadDebounceMillis = 100,
height = '100%',
width = '100%',
rowHeight,
headerHeight,
suppressHorizontalScroll,
animateRows,
className,
style,
loadingOverlayText,
noRowsOverlayText,
automat,
} = this.props;
const effectiveBlockSize = cacheBlockSize ?? automat.state._blockSize ?? 100;
const mergedDefaultColDef: ColDef<TRow> = {
flex: 1,
minWidth: 100,
sortable: true,
resizable: true,
...defaultColDef,
};
const containerStyle: React.CSSProperties = {
height,
width,
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
...style,
};
const themeClass = this.props.theme ?? 'ag-theme-alpine';
const effectiveModules = this.props.modules ?? DEFAULT_GRID_MODULES;
return (
<AgGridProvider modules={effectiveModules}>
<div className={`${themeClass} ${className ?? ''}`} style={containerStyle}>
<AgGridReact<TRow>
theme="legacy"
columnDefs={columnDefs}
defaultColDef={mergedDefaultColDef}
rowModelType="infinite"
cacheBlockSize={effectiveBlockSize}
cacheOverflowSize={2}
rowBuffer={rowBuffer}
infiniteInitialRowCount={infiniteInitialRowCount}
maxConcurrentDatasourceRequests={maxConcurrentDatasourceRequests}
maxBlocksInCache={maxBlocksInCache}
blockLoadDebounceMillis={blockLoadDebounceMillis}
rowHeight={rowHeight}
headerHeight={headerHeight}
suppressHorizontalScroll={suppressHorizontalScroll}
animateRows={animateRows}
getRowId={this.getRowId}
onGridReady={this.handleGridReady}
onSortChanged={this.handleSortChanged}
onFilterChanged={this.handleFilterChanged}
overlayLoadingTemplate={
loadingOverlayText
? `<span class="ag-overlay-loading-center">${loadingOverlayText}</span>`
: undefined
}
overlayNoRowsTemplate={
noRowsOverlayText
? `<span class="ag-overlay-no-rows-center">${noRowsOverlayText}</span>`
: undefined
}
/>
</div>
</AgGridProvider>
);
}
}

View File

@@ -0,0 +1,159 @@
/**
* createGridAutomat.ts
*
* Factory that creates a react-automat store to back an AG Grid with
* infinite-scroll (virtual) row loading. The automat owns the grid's
* data lifecycle — fetching pages, caching blocks, tracking sort/filter,
* and exposing reload/invalidate actions.
*
* Usage:
* const gridAutomat = createGridAutomat({ fetchRows, name: 'myGrid' });
*
* The returned automat is consumed by <AutomatGrid />.
*/
import { Automat } from 'react-automat';
import type { SortModelItem } from 'ag-grid-community';
export type { SortModelItem } from 'ag-grid-community';
// ─── Public types ────────────────────────────────────────────────
/** The shape of a single page request sent to the consumer's fetch function. */
export interface GridFetchParams {
startRow: number;
endRow: number;
sortModel: SortModelItem[];
filterModel: Record<string, unknown>;
}
/** Consumer-provided async function that returns a page of data. */
export interface GridFetchResult<TRow = Record<string, unknown>> {
rows: TRow[];
/** Total row count. Pass `undefined` / `-1` if unknown (infinite). */
lastRow?: number;
}
export type GridFetcher<TRow = Record<string, unknown>> = (
params: GridFetchParams,
) => Promise<GridFetchResult<TRow>>;
/** Configuration for createGridAutomat(). */
export interface GridAutomatOptions<TRow = Record<string, unknown>> {
/** Async data fetcher invoked by the AG Grid datasource. */
fetchRows: GridFetcher<TRow>;
/** Optional automat registry name (visible in window.__AUTOMATS__). */
name?: string;
/** Cache block size passed to AG Grid's `cacheBlockSize`. Default 100. */
blockSize?: number;
}
// ─── State shape ─────────────────────────────────────────────────
export interface GridAutomatState<TRow = Record<string, unknown>> {
/** Whether the very first block has finished loading. */
ready: boolean;
/** True while any block request is in-flight. */
loading: boolean;
/** Total known row count (may be -1 / undefined while streaming). */
totalRows: number;
/** Last-applied sort model (mirrors AG Grid). */
sortModel: SortModelItem[];
/** Last-applied filter model (mirrors AG Grid). */
filterModel: Record<string, unknown>;
/** An incrementing counter — bumped on reload() to force AG Grid datasource reset. */
revision: number;
/** Optional error from the last failed fetch. */
error: string | null;
/** The fetcher — stored in state so AutomatGrid can read it. */
_fetcher: GridFetcher<TRow>;
/** Block size config. */
_blockSize: number;
}
export interface GridAutomatActions {
/** Force a full reload (clears cache, bumps revision). */
reload: () => void;
/** Update the sort model externally (triggers reload). */
setSort: (sortModel: SortModelItem[]) => void;
/** Update the filter model externally (triggers reload). */
setFilter: (filterModel: Record<string, unknown>) => void;
/** Mark a fetch as started (called internally by the datasource adapter). */
_fetchStarted: () => void;
/** Mark a fetch as completed (called internally by the datasource adapter). */
_fetchCompleted: (totalRows: number) => void;
/** Mark a fetch as failed. */
_fetchFailed: (error: string) => void;
}
// ─── Factory ─────────────────────────────────────────────────────
export function createGridAutomat<TRow = Record<string, unknown>>(
options: GridAutomatOptions<TRow>,
): Automat<GridAutomatState<TRow>, GridAutomatActions> {
const { fetchRows, name, blockSize = 100 } = options;
const initialState: GridAutomatState<TRow> = {
ready: false,
loading: false,
totalRows: -1,
sortModel: [],
filterModel: {},
revision: 0,
error: null,
_fetcher: fetchRows,
_blockSize: blockSize,
};
const automat: Automat<GridAutomatState<TRow>, GridAutomatActions> = new Automat(
initialState,
{
reload() {
automat.setState((prev) => ({
revision: prev.revision + 1,
ready: false,
loading: false,
totalRows: -1,
error: null,
}));
},
setSort(sortModel: SortModelItem[]) {
automat.setState((prev) => ({
sortModel,
revision: prev.revision + 1,
ready: false,
loading: false,
totalRows: -1,
error: null,
}));
},
setFilter(filterModel: Record<string, unknown>) {
automat.setState((prev) => ({
filterModel,
revision: prev.revision + 1,
ready: false,
loading: false,
totalRows: -1,
error: null,
}));
},
_fetchStarted() {
automat.setState({ loading: true, error: null });
},
_fetchCompleted(totalRows: number) {
automat.setState({ loading: false, ready: true, totalRows });
},
_fetchFailed(error: string) {
automat.setState({ loading: false, error });
},
} as GridAutomatActions,
{
name: name ?? null,
},
);
return automat;
}

20
src/lib/index.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Library barrel export.
*
* Consumers import from 'react-virtual-aggridautomat':
* import { AutomatGrid, createGridAutomat } from 'react-virtual-aggridautomat';
*/
export { AutomatGrid } from './AutomatGrid';
export type { AutomatGridProps } from './AutomatGrid';
export { createGridAutomat } from './createGridAutomat';
export type {
GridAutomatOptions,
GridAutomatState,
GridAutomatActions,
GridFetchParams,
GridFetchResult,
GridFetcher,
SortModelItem,
} from './createGridAutomat';

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import 'prismjs/themes/prism.css';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

26
tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

15
vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// The git HEAD of react-automat doesn't ship pre-built dist/.
// Resolve directly to the ESM source so Vite bundles it.
'react-automat': path.resolve(import.meta.dirname, 'node_modules/react-automat/src/lib/index.js'),
},
},
})