Genesis
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
48
AGENTS.md
Normal 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
12
LICENSE
Normal 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
232
README.md
Normal 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.
|
||||||
|
|
||||||
|
[](https://www.ag-grid.com/)
|
||||||
|
[](https://github.com/sebseb7/react-automat)
|
||||||
|
[](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
22
eslint.config.js
Normal 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
14
index.html
Normal 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
3282
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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
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
24
public/icons.svg
Normal 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
156
src/App.tsx
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
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
208
src/demo/CodeViewer.tsx
Normal 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
517
src/demo/DemoGrid.tsx
Normal 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 (<500)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`demo-filter-btn ${filterModel.price ? 'active' : ''}`}
|
||||||
|
onClick={() => this.handleSetFilterPreset({ price: { filterType: 'number', type: 'lessThan', filter: 50 } })}
|
||||||
|
>
|
||||||
|
🏷 Price < €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
195
src/demo/DocViewer.tsx
Normal 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
222
src/demo/demoAutomat.ts
Normal 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
429
src/index.css
Normal 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
393
src/lib/AutomatGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/lib/createGridAutomat.ts
Normal file
159
src/lib/createGridAutomat.ts
Normal 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
20
src/lib/index.ts
Normal 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
13
src/main.tsx
Normal 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
26
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal 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
15
vite.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user