Compare commits

38 Commits

Author SHA1 Message Date
Hein
93568891cd feat(tree): add flat and lazy modes for tree data handling
* Implement flat mode to transform flat data with parentId to nested structure.
* Introduce lazy mode for on-demand loading of children.
* Update tree rendering logic to accommodate new modes.
* Enhance tree cell expansion logic to support lazy loading.
* Add dark mode styles for improved UI experience.
* Create comprehensive end-to-end tests for tree functionality.
2026-02-17 13:03:20 +02:00
9ddc960578 feat(tests): add comprehensive tree structure tests for various modes 2026-02-17 00:06:15 +02:00
Hein
78468455eb Latest changes 2026-02-16 22:48:48 +02:00
391450f615 feat(pagination): add pagination state management and cursor handling 2026-02-15 22:24:38 +02:00
7244bd33fc refactor(advancedSearch): reorder exports and improve type definitions
refactor(types): reorganize SearchCondition and AdvancedSearchState interfaces
refactor(filterPresets): streamline useFilterPresets hook and localStorage handling
refactor(filtering): clean up ColumnFilterButton and ColumnFilterPopover components
refactor(loading): separate GriddyLoadingOverlay from GriddyLoadingSkeleton
refactor(searchHistory): enhance useSearchHistory hook with persistence
refactor(index): update exports for adapters and core components
refactor(rendering): improve EditableCell and TableCell components for clarity
refactor(rendering): enhance TableHeader and VirtualBody components for better readability
2026-02-15 19:54:33 +02:00
9ec2e73640 feat(search): add search history functionality with dropdown and persistence
- Implement SearchHistoryDropdown component for displaying recent searches
- Add useSearchHistory hook for managing search history in localStorage
- Integrate search history into SearchOverlay for user convenience
- Update GridToolbar to support filter presets
- Enhance SearchOverlay with close button and history display
2026-02-15 13:52:36 +02:00
6226193ab5 feat(summary): update implementation status and add completed phases 2026-02-14 21:27:20 +02:00
e776844588 feat(core): add column pinning and grouping features to Griddy table
- Implement column pinning functionality allowing users to pin columns to the left or right.
- Introduce data grouping capabilities for better data organization.
- Enhance the theming guide with new styles for pinned columns and loading indicators.
- Add infinite scroll support with loading indicators for improved user experience.
- Update CSS styles to accommodate new features and improve visual feedback.
2026-02-14 21:18:04 +02:00
ad325d94a9 feat(toolbar): add column visibility and CSV export features
- Implemented GridToolbar component for column visibility and CSV export
- Added ColumnVisibilityMenu for toggling column visibility
- Created exportToCsv function for exporting visible data to CSV
- Updated Griddy component to integrate toolbar functionality
- Enhanced documentation with examples for new features
2026-02-14 14:51:53 +02:00
635da0ea18 feat(pagination): add server-side pagination support and controls
- Implement pagination control UI with page navigation and size selector
- Enable server-side callbacks for page changes and size adjustments
- Integrate pagination into Griddy component with data count handling
2026-02-14 14:43:36 +02:00
Hein
b49d008745 chore: griddy work 2026-02-13 17:09:49 +02:00
7ecafc8461 A Griddy AI prototype 2026-02-12 22:02:39 +02:00
e45a4d70f6 ... 2026-02-12 21:20:23 +02:00
Hein
6d73e83fbf docs(plan): add feature complete implementation plan 2026-02-12 18:55:15 +02:00
Hein
6dadbc9ba6 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.49

[skip ci]
2026-02-11 15:24:49 +02:00
Hein
74549f2f11 docs(changeset): fix(Gridler): refresh cells after data load 2026-02-11 15:24:46 +02:00
Hein
f47a230b62 fix(Gridler): refresh cells after data load 2026-02-11 15:24:24 +02:00
Hein
fb3a1e1054 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.48

[skip ci]
2026-02-11 15:11:39 +02:00
Hein
fd9af3d4ad docs(changeset): fix(Gridler): improve height and width fallback logic 2026-02-11 15:11:37 +02:00
Hein
cc12c0c3b8 fix(Gridler): improve height and width fallback logic 2026-02-11 15:11:13 +02:00
Hein
483d78c45d RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.47

[skip ci]
2026-02-11 14:29:57 +02:00
Hein
a15b67f30a docs(changeset): fix(Gridler): update ready state management logic 2026-02-11 14:29:52 +02:00
Hein
28ccd8af56 fix(Gridler): update ready state management logic 2026-02-11 14:29:24 +02:00
Hein
3887d08fca fix(Gridler): correct row index calculation logic
* Update row index checks to handle negative values correctly
* Simplify logic for determining row index from API
2026-02-11 14:22:51 +02:00
Hein
b43072f1cf RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.46

[skip ci]
2026-02-11 11:10:12 +02:00
Hein
0b2ab98fcf docs(changeset): row selection with incorrect values 2026-02-11 11:10:09 +02:00
Hein
afb7a3346f fix: row selection with incorrect values 2026-02-11 11:09:35 +02:00
Hein
95e2973d44 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.45

[skip ci]
2026-02-11 10:57:54 +02:00
Hein
cb340b2a13 docs(changeset): chore: ⬆ Update deps
fix: empty key, should no do rownumber call
2026-02-11 10:57:50 +02:00
Hein
e1b26f3f77 chore: ⬆️ Update deps 2026-02-11 10:56:41 +02:00
Hein
580c4b21cd fix: empty key, should no do rownumber call 2026-02-11 10:54:11 +02:00
Hein
7c5935c362 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.44

[skip ci]
2026-02-09 15:14:43 +02:00
Hein
40ae30e6ea docs(changeset): Select first row bug fixes 2026-02-09 15:14:40 +02:00
Hein
a1f34fbf7b fix(Computer): 🐛 improve row selection logic and cleanup
* Refactor row selection to handle first row more efficiently.
* Remove unnecessary checks and console logs for cleaner code.
* Update dependencies for better performance.
2026-02-09 15:13:59 +02:00
Hein
e48ab9b686 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.43

[skip ci]
2026-02-09 14:45:49 +02:00
Hein
3f9c4c5539 docs(changeset): Gridler selection fixes 2026-02-09 14:45:47 +02:00
Hein
6cb50978d0 fix(Gridler): 🐛 improve state management and cleanup
* Update `askAPIRowNumber` to return `null` or `number` for better type safety.
* Refactor conditionals to ensure proper handling of row indices.
* Clean up console logs for improved readability and performance.
* Ensure consistent formatting across the codebase.
2026-02-09 14:41:49 +02:00
31e46e6bd2 fix(Former): require 'opened' in useFormerStateProps for consistency 2026-02-08 16:21:24 +02:00
105 changed files with 15234 additions and 484 deletions

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ dist-ssr
*storybook.log
storybook-static
test-results/
playwright-report/

View File

@@ -1,13 +1,13 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [],
"framework": {
"name": "@storybook/react-vite",
"options": {}
}
addons: [],
framework: {
name: '@storybook/react-vite',
options: {
strictMode: true,
},
},
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
};
export default config;

View File

@@ -4,6 +4,23 @@ import { PreviewDecorator } from './previewDecorator';
const preview: Preview = {
decorators: [PreviewDecorator],
globalTypes: {
colorScheme: {
description: 'Mantine color scheme',
toolbar: {
dynamicTitle: true,
icon: 'paintbrush',
items: [
{ icon: 'sun', title: 'Light', value: 'light' },
{ icon: 'moon', title: 'Dark', value: 'dark' },
],
title: 'Color Scheme',
},
},
},
initialGlobals: {
colorScheme: 'light',
},
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
@@ -13,6 +30,7 @@ const preview: Preview = {
},
},
layout: 'fullscreen',
viewMode: 'desktop',
},
};

View File

@@ -8,7 +8,8 @@ import { ModalsProvider } from '@mantine/modals';
import { GlobalStateStoreProvider } from '../src/GlobalStateStore';
export const PreviewDecorator: Decorator = (Story, context) => {
const { parameters } = context;
const { parameters, globals } = context;
const colorScheme = globals.colorScheme as 'light' | 'dark';
// Allow stories to opt-out of GlobalStateStore provider
const useGlobalStore = parameters.globalStore !== false;
@@ -21,7 +22,8 @@ export const PreviewDecorator: Decorator = (Story, context) => {
};
return (
<MantineProvider>
<MantineProvider forceColorScheme={colorScheme}>
<ModalsProvider>
{useGlobalStore ? (
<GlobalStateStoreProvider fetchOnMount={false}>

View File

@@ -1,5 +1,48 @@
# @warkypublic/zustandsyncstore
## 0.0.49
### Patch Changes
- 74549f2: fix(Gridler): refresh cells after data load
## 0.0.48
### Patch Changes
- fd9af3d: fix(Gridler): improve height and width fallback logic
## 0.0.47
### Patch Changes
- a15b67f: fix(Gridler): update ready state management logic
## 0.0.46
### Patch Changes
- 0b2ab98: row selection with incorrect values
## 0.0.45
### Patch Changes
- cb340b2: chore: ⬆ Update deps
fix: empty key, should no do rownumber call
## 0.0.44
### Patch Changes
- 40ae30e: Select first row bug fixes
## 0.0.43
### Patch Changes
- 3f9c4c5: Gridler selection fixes
## 0.0.42
### Patch Changes

263
llm/docs/resolvespec-js.md Normal file
View File

@@ -0,0 +1,263 @@
# @warkypublic/resolvespec-js v1.0.0
TypeScript client library for ResolveSpec APIs. Supports body-based REST, header-based REST, and WebSocket protocols. Aligns with Go backend types.
## Clients
| Client | Protocol | Singleton Factory |
|---|---|---|
| `ResolveSpecClient` | REST (body JSON) | `getResolveSpecClient(config)` |
| `HeaderSpecClient` | REST (HTTP headers) | `getHeaderSpecClient(config)` |
| `WebSocketClient` | WebSocket | `getWebSocketClient(config)` |
Singleton factories cache instances keyed by URL.
## Config
```typescript
interface ClientConfig {
baseUrl: string;
token?: string; // Bearer token
}
interface WebSocketClientConfig {
url: string;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
heartbeatInterval?: number;
debug?: boolean;
}
```
## ResolveSpecClient (Body-Based REST)
```typescript
import { ResolveSpecClient, getResolveSpecClient } from '@warkypublic/resolvespec-js';
const client = new ResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// CRUD - signature: (schema, entity, id?, options?)
await client.read('public', 'users', undefined, { columns: ['id', 'name'], limit: 10 });
await client.read('public', 'users', 42); // by ID
await client.create('public', 'users', { name: 'New' }); // create
await client.update('public', 'users', { name: 'Updated' }, 42); // update
await client.delete('public', 'users', 42); // delete
await client.getMetadata('public', 'users'); // table metadata
```
**Method signatures:**
- `read<T>(schema, entity, id?: number|string|string[], options?): Promise<APIResponse<T>>`
- `create<T>(schema, entity, data: any|any[], options?): Promise<APIResponse<T>>`
- `update<T>(schema, entity, data: any|any[], id?: number|string|string[], options?): Promise<APIResponse<T>>`
- `delete(schema, entity, id: number|string): Promise<APIResponse<void>>`
- `getMetadata(schema, entity): Promise<APIResponse<TableMetadata>>`
## HeaderSpecClient (Header-Based REST)
```typescript
import { HeaderSpecClient, getHeaderSpecClient } from '@warkypublic/resolvespec-js';
const client = new HeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
// CRUD - HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete
await client.read('public', 'users', undefined, { columns: ['id', 'name'], limit: 50 });
await client.create('public', 'users', { name: 'New' });
await client.update('public', 'users', '42', { name: 'Updated' });
await client.delete('public', 'users', '42');
```
**Method signatures:**
- `read<T>(schema, entity, id?: string, options?): Promise<APIResponse<T>>`
- `create<T>(schema, entity, data, options?): Promise<APIResponse<T>>`
- `update<T>(schema, entity, id: string, data, options?): Promise<APIResponse<T>>`
- `delete(schema, entity, id: string): Promise<APIResponse<void>>`
### Header Mapping
| Header | Options Field | Format |
|---|---|---|
| `X-Select-Fields` | `columns` | comma-separated |
| `X-Not-Select-Fields` | `omit_columns` | comma-separated |
| `X-FieldFilter-{col}` | `filters` (eq, AND) | value |
| `X-SearchOp-{op}-{col}` | `filters` (AND) | value |
| `X-SearchOr-{op}-{col}` | `filters` (OR) | value |
| `X-Sort` | `sort` | `+col` asc, `-col` desc |
| `X-Limit` / `X-Offset` | `limit` / `offset` | number |
| `X-Cursor-Forward` | `cursor_forward` | string |
| `X-Cursor-Backward` | `cursor_backward` | string |
| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated |
| `X-Fetch-RowNumber` | `fetch_row_number` | string |
| `X-CQL-SEL-{col}` | `computedColumns` | expression |
| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined |
### Utility Functions
```typescript
import { buildHeaders, encodeHeaderValue, decodeHeaderValue } from '@warkypublic/resolvespec-js';
buildHeaders({ columns: ['id', 'name'], limit: 10 });
// => { 'X-Select-Fields': 'id,name', 'X-Limit': '10' }
encodeHeaderValue('complex value'); // 'ZIP_...' (base64 encoded)
decodeHeaderValue(encoded); // original string
```
## WebSocketClient
```typescript
import { WebSocketClient, getWebSocketClient } from '@warkypublic/resolvespec-js';
const ws = new WebSocketClient({ url: 'ws://localhost:8080/ws', reconnect: true, heartbeatInterval: 30000 });
await ws.connect();
// CRUD
await ws.read('users', { schema: 'public', limit: 10, filters: [...], columns: [...] });
await ws.create('users', { name: 'New' }, { schema: 'public' });
await ws.update('users', '1', { name: 'Updated' }, { schema: 'public' });
await ws.delete('users', '1', { schema: 'public' });
await ws.meta('users', { schema: 'public' });
// Subscriptions
const subId = await ws.subscribe('users', (notification) => { ... }, { schema: 'public', filters: [...] });
await ws.unsubscribe(subId);
ws.getSubscriptions();
// Connection
ws.getState(); // 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'reconnecting'
ws.isConnected();
ws.disconnect();
// Events
ws.on('connect', () => {});
ws.on('disconnect', (event: CloseEvent) => {});
ws.on('error', (error: Error) => {});
ws.on('message', (message: WSMessage) => {});
ws.on('stateChange', (state: ConnectionState) => {});
ws.off('connect');
```
## Options (Query Parameters)
```typescript
interface Options {
columns?: string[];
omit_columns?: string[];
filters?: FilterOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
preload?: PreloadOption[];
customOperators?: CustomOperator[];
computedColumns?: ComputedColumn[];
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
```
### FilterOption
```typescript
interface FilterOption {
column: string;
operator: Operator | string;
value: any;
logic_operator?: 'AND' | 'OR';
}
// Operators: eq, neq, gt, gte, lt, lte, like, ilike, in,
// contains, startswith, endswith, between,
// between_inclusive, is_null, is_not_null
```
### SortOption
```typescript
interface SortOption {
column: string;
direction: 'asc' | 'desc' | 'ASC' | 'DESC';
}
```
### PreloadOption
```typescript
interface PreloadOption {
relation: string;
table_name?: string;
columns?: string[];
omit_columns?: string[];
sort?: SortOption[];
filters?: FilterOption[];
where?: string;
limit?: number;
offset?: number;
updatable?: boolean;
computed_ql?: Record<string, string>;
recursive?: boolean;
primary_key?: string;
related_key?: string;
foreign_key?: string;
recursive_child_key?: string;
sql_joins?: string[];
join_aliases?: string[];
}
```
### Other Types
```typescript
interface ComputedColumn { name: string; expression: string; }
interface CustomOperator { name: string; sql: string; }
interface Parameter { name: string; value: string; sequence?: number; }
```
## Response Types
```typescript
interface APIResponse<T = any> {
success: boolean;
data: T;
metadata?: Metadata;
error?: APIError;
}
interface APIError { code: string; message: string; details?: any; detail?: string; }
interface Metadata { total: number; count: number; filtered: number; limit: number; offset: number; row_number?: number; }
interface TableMetadata {
schema: string;
table: string;
columns: Column[];
relations: string[];
}
interface Column { name: string; type: string; is_nullable: boolean; is_primary: boolean; is_unique: boolean; has_index: boolean; }
```
## WebSocket Message Types
```typescript
type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta';
interface WSMessage {
id?: string; type: MessageType; operation?: WSOperation;
schema?: string; entity?: string; record_id?: string;
data?: any; options?: WSOptions; subscription_id?: string;
success?: boolean; error?: WSErrorInfo; metadata?: Record<string, any>; timestamp?: string;
}
interface WSNotificationMessage {
type: 'notification'; operation: WSOperation; subscription_id: string;
schema?: string; entity: string; data: any; timestamp: string;
}
```
## Dependencies
- Runtime: `uuid`
- Peer: none
- Node >= 18

View File

@@ -0,0 +1,131 @@
# @warkypublic/zustandsyncstore v1.0.0
React library providing synchronized Zustand stores with prop-based state management and persistence support.
## Peer Dependencies
- `react` >= 19.0.0
- `zustand` >= 5.0.0
- `use-sync-external-store` >= 1.4.0
## Runtime Dependencies
- `@warkypublic/artemis-kit`
## API
Single export: `createSyncStore`
```typescript
import { createSyncStore } from '@warkypublic/zustandsyncstore';
```
### createSyncStore<TState, TProps>(createState?, useValue?)
**Parameters:**
- `createState` (optional): Zustand `StateCreator<TState>` function
- `useValue` (optional): Custom hook receiving `{ useStore, useStoreApi } & TProps`, returns additional state to merge
**Returns:** `SyncStoreReturn<TState, TProps>` containing:
- `Provider` — React context provider component
- `useStore` — Hook to access the store
### Provider Props
| Prop | Type | Description |
|---|---|---|
| `children` | `ReactNode` | Required |
| `firstSyncProps` | `string[]` | Props to sync only on first render |
| `persist` | `PersistOptions<Partial<TProps & TState>>` | Zustand persist config |
| `waitForSync` | `boolean` | Wait for sync before rendering children |
| `fallback` | `ReactNode` | Shown while waiting for sync |
| `...TProps` | `TProps` | Custom props synced to store state |
### useStore Hook
```typescript
const state = useStore(); // entire state (TState & TProps)
const count = useStore(state => state.count); // with selector
const count = useStore(state => state.count, (a, b) => a === b); // with equality fn
```
## Usage
### Basic
```tsx
interface MyState { count: number; increment: () => void; }
interface MyProps { initialCount: number; }
const { Provider, useStore } = createSyncStore<MyState, MyProps>(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
})
);
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>Count: {count}</button>;
}
function App() {
return (
<Provider initialCount={10}>
<Counter />
</Provider>
);
}
```
### With Custom Hook Logic
```tsx
const { Provider, useStore } = createSyncStore<MyState, MyProps>(
(set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })) }),
({ useStore, useStoreApi, initialCount }) => {
const currentCount = useStore(state => state.count);
return { computedValue: initialCount * 2 };
}
);
```
### With Persistence
```tsx
<Provider initialCount={10} persist={{ name: 'my-store', storage: localStorage }}>
<Counter />
</Provider>
```
### Selective Prop Syncing
```tsx
<Provider initialCount={10} otherProp="value" firstSyncProps={['initialCount']}>
<Counter />
</Provider>
```
## Internal Types
```typescript
type LocalUseStore<TState, TProps> = TState & TProps;
// Store state includes a $sync method for internal prop syncing
type InternalStoreState<TState, TProps> = TState & TProps & {
$sync: (props: TProps) => void;
};
type SyncStoreReturn<TState, TProps> = {
Provider: (props: { children: ReactNode } & {
firstSyncProps?: string[];
persist?: PersistOptions<Partial<TProps & TState>>;
waitForSync?: boolean;
fallback?: ReactNode;
} & TProps) => React.ReactNode;
useStore: {
(): LocalUseStore<TState, TProps>;
<U>(selector: (state: LocalUseStore<TState, TProps>) => U, equalityFn?: (a: U, b: U) => boolean): U;
};
};
```

View File

@@ -1,7 +1,7 @@
{
"name": "@warkypublic/oranguru",
"author": "Warky Devs",
"version": "0.0.42",
"version": "0.0.49",
"type": "module",
"types": "./dist/lib.d.ts",
"main": "./dist/lib.cjs.js",
@@ -48,34 +48,38 @@
"url": "git+https://git.warky.dev/wdevs/oranguru.git"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@mantine/dates": "^8.3.14",
"@modelcontextprotocol/sdk": "^1.26.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"dayjs": "^1.11.19",
"moment": "^2.30.1"
},
"devDependencies": {
"@changesets/changelog-git": "^0.2.1",
"@changesets/cli": "^2.29.8",
"@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.56.0",
"@eslint/js": "^10.0.1",
"@microsoft/api-extractor": "^7.56.3",
"@playwright/test": "^1.58.2",
"@sentry/react": "^10.38.0",
"@storybook/react-vite": "^10.2.3",
"@storybook/react-vite": "^10.2.8",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jsdom": "~27.0.0",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/use-sync-external-store": "~1.5.0",
"@typescript-eslint/parser": "^8.54.0",
"@typescript-eslint/parser": "^8.55.0",
"@vitejs/plugin-react-swc": "^4.2.3",
"eslint": "^9.39.2",
"eslint": "^10.0.0",
"eslint-config-mantine": "^4.0.3",
"eslint-plugin-perfectionist": "^5.4.0",
"eslint-plugin-perfectionist": "^5.5.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"eslint-plugin-storybook": "^10.2.3",
"eslint-plugin-storybook": "^10.2.8",
"global": "^4.4.0",
"globals": "^17.3.0",
"jiti": "^2.6.1",
@@ -87,12 +91,12 @@
"prettier-eslint": "^16.4.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"storybook": "^10.2.3",
"storybook": "^10.2.8",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0",
"typescript-eslint": "^8.55.0",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^6.0.5",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18"
},
"peerDependencies": {
@@ -103,8 +107,10 @@
"@mantine/notifications": "^8.3.5",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-table": "^8.21.3",
"@warkypublic/artemis-kit": "^1.0.10",
"@warkypublic/zustandsyncstore": "^0.0.4",
"@warkypublic/zustandsyncstore": "^1.0.0",
"@warkypublic/resolvespec-js": "^1.0.1",
"idb-keyval": "^6.2.2",
"immer": "^10.1.3",
"react": ">= 19.0.0",

File diff suppressed because one or more lines are too long

23
playwright.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
forbidOnly: !!process.env.CI,
fullyParallel: true,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
reporter: 'html',
retries: process.env.CI ? 2 : 0,
testDir: './tests/e2e',
use: {
baseURL: 'http://localhost:6006',
trace: 'on-first-retry',
},
webServer: undefined,
workers: process.env.CI ? 1 : undefined,
});

932
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ export const useFormerState = <T extends FieldValues = FieldValues>(
former: { ...formerProps, onChange },
formerWrapper: { onClose, opened } as {
onClose: Required<UseFormerStateProps<T>>['onClose'];
opened: UseFormerStateProps<T>['opened'];
opened: Required<UseFormerStateProps<T>>['opened'];
},
open: (request: UseFormerStateProps<T>['request'], data: UseFormerStateProps<T>['values']) => {
setState((cv) => ({ ...cv, opened: true, primeData: data, request, values: data }));

195
src/Griddy/CONTEXT.md Normal file
View File

@@ -0,0 +1,195 @@
# Griddy - Implementation Context
## What Is This
Griddy is a data grid component in the Oranguru package (`@warkypublic/oranguru`), built on TanStack Table + TanStack Virtual with Zustand state management.
## Architecture
### Two TanStack Libraries
- **@tanstack/react-table** (headless table model): sorting, filtering, pagination, row selection, column visibility, grouping
- **@tanstack/react-virtual** (virtualization): renders only visible rows from the table's row model
### State Management
- **createSyncStore** from `@warkypublic/zustandsyncstore`
- `GriddyProvider` wraps children; props auto-sync into the store via `$sync`
- `useGriddyStore((s) => s.fieldName)` to read any prop or UI state
- `GriddyStoreState` must explicitly declare all prop fields from `GriddyProps` for TypeScript visibility
- UI state (focus, edit mode, search overlay, selection mode) lives in the store
- TanStack Table/Virtual instances stored as `_table`, `_virtualizer` in the store
### Component Tree
```
<Griddy props> // forwardRef wrapper
<GriddyProvider {...props}> // createSyncStore Provider, syncs all props
<GriddyErrorBoundary> // class-based error boundary with retry
<GriddyInner> // sets up useReactTable + useVirtualizer
<SearchOverlay /> // Ctrl+F search (with search history)
<AdvancedSearchPanel /> // multi-condition boolean search
<GridToolbar /> // export, column visibility, filter presets
<div tabIndex={0}> // scroll container, keyboard target
<TableHeader /> // headers, sort indicators, filter popovers
<GriddyLoadingSkeleton /> // shown when isLoading && no data
<VirtualBody /> // maps virtualizer items -> TableRow
<TableRow /> // focus/selection CSS, click handler
<TableCell /> // flexRender, editors, custom renderers
<GriddyLoadingOverlay /> // translucent overlay when loading with data
</div>
<PaginationControl /> // page nav, page size selector
</GriddyInner>
</GriddyErrorBoundary>
</GriddyProvider>
</Griddy>
```
## File Structure
```
src/Griddy/
├── core/
│ ├── Griddy.tsx # Main component, useReactTable + useVirtualizer
│ ├── GriddyStore.ts # Zustand store (createSyncStore)
│ ├── types.ts # All interfaces: GriddyColumn, GriddyProps, GriddyRef, etc.
│ ├── columnMapper.ts # GriddyColumn -> TanStack ColumnDef, checkbox column
│ └── constants.ts # CSS class names, defaults (row height 36, overscan 10)
├── rendering/
│ ├── VirtualBody.tsx # Virtual row rendering
│ ├── TableHeader.tsx # Headers, sort, resize, filter popovers, drag reorder
│ ├── TableRow.tsx # Row with focus/selection styling
│ ├── TableCell.tsx # Cell via flexRender, checkbox, editing
│ ├── EditableCell.tsx # Editor mounting wrapper
│ └── hooks/
│ └── useGridVirtualizer.ts
├── editors/
│ ├── TextEditor.tsx, NumericEditor.tsx, DateEditor.tsx
│ ├── SelectEditor.tsx, CheckboxEditor.tsx
│ ├── types.ts, index.ts
├── features/
│ ├── errorBoundary/
│ │ └── GriddyErrorBoundary.tsx # Class-based, onError/onRetry callbacks
│ ├── loading/
│ │ └── GriddyLoadingSkeleton.tsx # Skeleton rows + overlay spinner
│ ├── renderers/
│ │ ├── ProgressBarRenderer.tsx # Percentage bar via rendererMeta
│ │ ├── BadgeRenderer.tsx # Colored pill badges
│ │ ├── ImageRenderer.tsx # Thumbnail images
│ │ └── SparklineRenderer.tsx # SVG polyline sparklines
│ ├── filtering/
│ │ ├── ColumnFilterPopover.tsx # Filter UI with quick filter integration
│ │ ├── ColumnFilterButton.tsx # Filter icon (forwardRef, onClick toggle)
│ │ ├── ColumnFilterContextMenu.tsx # Right-click: Sort, Open Filters
│ │ ├── FilterInput.tsx, FilterSelect.tsx, FilterBoolean.tsx, FilterDate.tsx
│ │ ├── filterFunctions.ts, operators.ts, types.ts
│ ├── quickFilter/
│ │ └── QuickFilterDropdown.tsx # Checkbox list of unique column values
│ ├── advancedSearch/
│ │ ├── AdvancedSearchPanel.tsx # Multi-condition search panel
│ │ ├── SearchConditionRow.tsx # Single condition: column + operator + value
│ │ ├── advancedFilterFn.ts # AND/OR/NOT filter logic
│ │ └── types.ts
│ ├── filterPresets/
│ │ ├── FilterPresetsMenu.tsx # Save/load/delete presets dropdown
│ │ ├── useFilterPresets.ts # localStorage CRUD hook
│ │ └── types.ts
│ ├── search/
│ │ └── SearchOverlay.tsx # Ctrl+F search with history integration
│ ├── searchHistory/
│ │ ├── SearchHistoryDropdown.tsx # Recent searches dropdown
│ │ └── useSearchHistory.ts # localStorage hook (last 10 searches)
│ ├── keyboard/
│ │ └── useKeyboardNavigation.ts
│ ├── pagination/
│ │ └── PaginationControl.tsx
│ ├── toolbar/
│ │ └── GridToolbar.tsx # Export, column visibility, filter presets
│ ├── export/
│ │ └── exportCsv.ts
│ └── columnVisibility/
│ └── ColumnVisibilityMenu.tsx
├── styles/
│ └── griddy.module.css
├── index.ts
└── Griddy.stories.tsx # 31 stories covering all features
tests/e2e/
├── filtering-context-menu.spec.ts # 8 tests for Phase 5 filtering
└── griddy-features.spec.ts # 26 tests for Phase 10 features
```
## Key Props (GriddyProps<T>)
| Prop | Type | Purpose |
|------|------|---------|
| `data` | `T[]` | Data array |
| `columns` | `GriddyColumn<T>[]` | Column definitions |
| `selection` | `SelectionConfig` | none/single/multi row selection |
| `search` | `SearchConfig` | Ctrl+F search overlay |
| `advancedSearch` | `{ enabled }` | Multi-condition search panel |
| `pagination` | `PaginationConfig` | Client/server-side pagination |
| `grouping` | `GroupingConfig` | Data grouping |
| `isLoading` | `boolean` | Show skeleton/overlay |
| `showToolbar` | `boolean` | Export + column visibility toolbar |
| `filterPresets` | `boolean` | Save/load filter presets |
| `onError` | `(error) => void` | Error boundary callback |
| `onRetry` | `() => void` | Error boundary retry callback |
| `onEditCommit` | `(rowId, colId, value) => void` | Edit callback |
| `manualSorting/manualFiltering` | `boolean` | Server-side mode |
| `persistenceKey` | `string` | localStorage key for presets/history |
## GriddyColumn<T> Key Fields
| Field | Purpose |
|-------|---------|
| `renderer` | Custom cell renderer (wired via columnMapper `def.cell`) |
| `rendererMeta` | Metadata for built-in renderers (colorMap, max, etc.) |
| `filterConfig` | `{ type, quickFilter?, enumOptions? }` |
| `editable` | `boolean \| (row) => boolean` |
| `editorConfig` | Editor-specific config (options, min, max, etc.) |
| `pinned` | `'left' \| 'right'` |
| `headerGroup` | Groups columns under parent header |
## Keyboard Bindings
- Arrow Up/Down: move focus
- Page Up/Down: jump by visible page
- Home/End: first/last row
- Space: toggle selection
- Shift+Arrow: extend multi-selection
- Ctrl+A: select all (multi mode)
- Ctrl+F: open search overlay
- Ctrl+E / Enter: enter edit mode
- Escape: close search / cancel edit / clear selection
## Gotchas / Bugs Fixed
1. **Hooks violation in VirtualBody**: `useEffect` was after early `return null`. All hooks must run before any conditional return.
2. **sortingFn crash**: Setting `sortingFn: undefined` explicitly overrides TanStack's auto-detection. Fix: use `accessorKey` for string accessors.
3. **createSyncStore typing**: Props synced at runtime via `$sync` but TypeScript only sees `GriddyStoreState`. All prop fields must be declared in store state interface.
4. **useGriddyStore has no .getState()**: Context-based hook, not vanilla zustand. Use `useRef` for imperative access.
5. **globalFilterFn: undefined breaks search**: Explicitly setting `globalFilterFn: undefined` disables global filtering. Use conditional spread: `...(advancedSearch?.enabled ? { globalFilterFn } : {})`.
6. **Custom renderers not rendering**: `columnMapper.ts` must wire `GriddyColumn.renderer` into TanStack's `ColumnDef.cell`.
7. **Error boundary retry timing**: `onRetry` parent setState must flush before error boundary clears. Use `setTimeout(0)` to defer `setState({ error: null })`.
8. **ColumnFilterButton must forwardRef**: Mantine's `Popover.Target` requires child to forward refs.
9. **Filter popover click propagation**: Clicking filter icon bubbles to header cell (triggers sort). Fix: explicit `onClick` with `stopPropagation` on ColumnFilterButton, not relying on Mantine Popover.Target auto-toggle.
10. **header.getAfter('right')**: Method exists on `Column`, not `Header`. Use `header.column.getAfter('right')`.
## UI Components
Uses **Mantine** components:
- `Checkbox`, `TextInput`, `ActionIcon`, `Popover`, `Menu`, `Button`, `Group`, `Stack`, `Text`
- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `SegmentedControl`, `ScrollArea`
- `@mantine/dates` for DatePickerInput
- `@tabler/icons-react` for icons
## Implementation Status
- [x] Phase 1-9: Core, virtualization, selection, search, filtering, editing, pagination, advanced features, polish
- [x] Phase 7.5: Infinite scroll
- [x] Phase 8 completion: Column pinning, header grouping, data grouping, column reordering
- [x] Phase 10 (partial): Error boundary, loading states, custom renderers, quick filters, advanced search, filter presets, search history
- [ ] Phase 10 remaining: See plan.md
## E2E Tests
- **34 total Playwright tests** (8 filtering + 26 feature tests)
- All passing against Storybook at `http://localhost:6006`
- Run: `npx playwright test` (requires Storybook running)
## Commands
```bash
pnpm run typecheck && pnpm run build # Build check
pnpm run storybook # Start Storybook
npx playwright test # Run E2E tests
npx playwright test tests/e2e/griddy-features.spec.ts # Phase 10 tests only
```

471
src/Griddy/EXAMPLES.md Normal file
View File

@@ -0,0 +1,471 @@
# Griddy Examples
## Table of Contents
1. [Basic Grid](#basic-grid)
2. [Editable Grid](#editable-grid)
3. [Searchable Grid](#searchable-grid)
4. [Filtered Grid](#filtered-grid)
5. [Paginated Grid](#paginated-grid)
6. [Server-Side Grid](#server-side-grid)
7. [Custom Renderers](#custom-renderers)
8. [Selection](#selection)
9. [TypeScript Integration](#typescript-integration)
## Basic Grid
```typescript
import { Griddy, type GriddyColumn } from '@warkypublic/oranguru'
interface Product {
id: number
name: string
price: number
inStock: boolean
}
const columns: GriddyColumn<Product>[] = [
{ id: 'id', accessor: 'id', header: 'ID', width: 60 },
{ id: 'name', accessor: 'name', header: 'Product Name', width: 200, sortable: true },
{ id: 'price', accessor: 'price', header: 'Price', width: 100, sortable: true },
{ id: 'inStock', accessor: row => row.inStock ? 'Yes' : 'No', header: 'In Stock', width: 100 },
]
const data: Product[] = [
{ id: 1, name: 'Laptop', price: 999, inStock: true },
{ id: 2, name: 'Mouse', price: 29, inStock: false },
]
export function ProductGrid() {
return (
<Griddy
columns={columns}
data={data}
height={500}
getRowId={(row) => String(row.id)}
/>
)
}
```
## Editable Grid
```typescript
import { useState } from 'react'
import { Griddy, type GriddyColumn } from '@warkypublic/oranguru'
interface User {
id: number
firstName: string
lastName: string
age: number
role: string
}
export function EditableUserGrid() {
const [users, setUsers] = useState<User[]>([
{ id: 1, firstName: 'John', lastName: 'Doe', age: 30, role: 'Admin' },
{ id: 2, firstName: 'Jane', lastName: 'Smith', age: 25, role: 'User' },
])
const columns: GriddyColumn<User>[] = [
{ id: 'id', accessor: 'id', header: 'ID', width: 60 },
{
id: 'firstName',
accessor: 'firstName',
header: 'First Name',
width: 150,
editable: true,
editorConfig: { type: 'text' },
},
{
id: 'lastName',
accessor: 'lastName',
header: 'Last Name',
width: 150,
editable: true,
editorConfig: { type: 'text' },
},
{
id: 'age',
accessor: 'age',
header: 'Age',
width: 80,
editable: true,
editorConfig: { type: 'number', min: 18, max: 120 },
},
{
id: 'role',
accessor: 'role',
header: 'Role',
width: 120,
editable: true,
editorConfig: {
type: 'select',
options: [
{ label: 'Admin', value: 'Admin' },
{ label: 'User', value: 'User' },
{ label: 'Guest', value: 'Guest' },
],
},
},
]
const handleEditCommit = async (rowId: string, columnId: string, value: unknown) => {
setUsers(prev => prev.map(user =>
String(user.id) === rowId
? { ...user, [columnId]: value }
: user
))
}
return (
<Griddy
columns={columns}
data={users}
height={500}
getRowId={(row) => String(row.id)}
onEditCommit={handleEditCommit}
/>
)
}
```
## Searchable Grid
```typescript
import { Griddy, type GriddyColumn } from '@warkypublic/oranguru'
export function SearchableGrid() {
const columns: GriddyColumn<Person>[] = [
{ id: 'name', accessor: 'name', header: 'Name', width: 150, searchable: true },
{ id: 'email', accessor: 'email', header: 'Email', width: 250, searchable: true },
{ id: 'department', accessor: 'department', header: 'Department', width: 150 },
]
return (
<Griddy
columns={columns}
data={data}
height={500}
search={{
enabled: true,
highlightMatches: true,
placeholder: 'Search by name or email...',
}}
/>
)
}
```
## Filtered Grid
```typescript
import { useState } from 'react'
import { Griddy, type GriddyColumn } from '@warkypublic/oranguru'
import type { ColumnFiltersState } from '@tanstack/react-table'
export function FilteredGrid() {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const columns: GriddyColumn<Person>[] = [
{
id: 'name',
accessor: 'name',
header: 'Name',
filterable: true,
filterConfig: { type: 'text' },
width: 150,
},
{
id: 'age',
accessor: 'age',
header: 'Age',
filterable: true,
filterConfig: { type: 'number' },
width: 80,
},
{
id: 'department',
accessor: 'department',
header: 'Department',
filterable: true,
filterConfig: {
type: 'enum',
enumOptions: [
{ label: 'Engineering', value: 'Engineering' },
{ label: 'Marketing', value: 'Marketing' },
{ label: 'Sales', value: 'Sales' },
],
},
width: 150,
},
]
return (
<Griddy
columns={columns}
data={data}
height={500}
columnFilters={filters}
onColumnFiltersChange={setFilters}
/>
)
}
```
## Paginated Grid
```typescript
import { Griddy, type GriddyColumn } from '@warkypublic/oranguru'
export function PaginatedGrid() {
const columns: GriddyColumn<Person>[] = [
{ id: 'id', accessor: 'id', header: 'ID', width: 60 },
{ id: 'name', accessor: 'name', header: 'Name', width: 150 },
{ id: 'email', accessor: 'email', header: 'Email', width: 250 },
]
return (
<Griddy
columns={columns}
data={largeDataset}
height={500}
pagination={{
enabled: true,
pageSize: 25,
pageSizeOptions: [10, 25, 50, 100],
}}
/>
)
}
```
## Server-Side Grid
```typescript
import { useState, useEffect } from 'react'
import { Griddy, type GriddyColumn } from '@warkypublic/oranguru'
import type { ColumnFiltersState, SortingState } from '@tanstack/react-table'
export function ServerSideGrid() {
const [data, setData] = useState([])
const [totalCount, setTotalCount] = useState(0)
const [filters, setFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
const [pageIndex, setPageIndex] = useState(0)
const [pageSize, setPageSize] = useState(25)
const [isLoading, setIsLoading] = useState(false)
// Fetch data when filters, sorting, or pagination changes
useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filters,
sorting,
pagination: { pageIndex, pageSize },
}),
})
const result = await response.json()
setData(result.data)
setTotalCount(result.total)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [filters, sorting, pageIndex, pageSize])
const columns: GriddyColumn<Person>[] = [
{
id: 'name',
accessor: 'name',
header: 'Name',
sortable: true,
filterable: true,
filterConfig: { type: 'text' },
width: 150,
},
// ... more columns
]
return (
<Griddy
columns={columns}
data={data}
dataCount={totalCount}
height={500}
manualSorting
manualFiltering
columnFilters={filters}
onColumnFiltersChange={setFilters}
sorting={sorting}
onSortingChange={setSorting}
pagination={{
enabled: true,
pageSize,
pageSizeOptions: [10, 25, 50, 100],
onPageChange: setPageIndex,
onPageSizeChange: (size) => {
setPageSize(size)
setPageIndex(0)
},
}}
/>
)
}
```
## Custom Renderers
```typescript
import { Griddy, type GriddyColumn, type CellRenderer } from '@warkypublic/oranguru'
import { Badge } from '@mantine/core'
interface Order {
id: number
customer: string
amount: number
status: 'pending' | 'shipped' | 'delivered'
}
const StatusRenderer: CellRenderer<Order> = ({ value }) => {
const color = value === 'delivered' ? 'green' : value === 'shipped' ? 'blue' : 'yellow'
return <Badge color={color}>{String(value)}</Badge>
}
const AmountRenderer: CellRenderer<Order> = ({ value }) => {
const amount = Number(value)
const color = amount > 1000 ? 'green' : 'gray'
return <span style={{ color, fontWeight: 600 }}>${amount.toFixed(2)}</span>
}
export function OrderGrid() {
const columns: GriddyColumn<Order>[] = [
{ id: 'id', accessor: 'id', header: 'Order ID', width: 100 },
{ id: 'customer', accessor: 'customer', header: 'Customer', width: 200 },
{
id: 'amount',
accessor: 'amount',
header: 'Amount',
width: 120,
renderer: AmountRenderer,
},
{
id: 'status',
accessor: 'status',
header: 'Status',
width: 120,
renderer: StatusRenderer,
},
]
return <Griddy columns={columns} data={orders} height={500} />
}
```
## Selection
```typescript
import { useState } from 'react'
import { Griddy, type GriddyColumn } from '@warkypublic/oranguru'
import type { RowSelectionState } from '@tanstack/react-table'
export function SelectableGrid() {
const [selection, setSelection] = useState<RowSelectionState>({})
const columns: GriddyColumn<Person>[] = [
{ id: 'name', accessor: 'name', header: 'Name', width: 150 },
{ id: 'email', accessor: 'email', header: 'Email', width: 250 },
]
const selectedRows = Object.keys(selection).filter(key => selection[key])
return (
<>
<Griddy
columns={columns}
data={data}
height={500}
rowSelection={selection}
onRowSelectionChange={setSelection}
selection={{
mode: 'multi',
showCheckbox: true,
selectOnClick: true,
}}
/>
<div>Selected: {selectedRows.length} rows</div>
</>
)
}
```
## TypeScript Integration
```typescript
// Define your data type
interface Employee {
id: number
firstName: string
lastName: string
email: string
department: string
salary: number
hireDate: string
isActive: boolean
}
// Type-safe column definition
const columns: GriddyColumn<Employee>[] = [
{
id: 'id',
accessor: 'id', // Type-checked against Employee keys
header: 'ID',
width: 60,
},
{
id: 'fullName',
accessor: (row) => `${row.firstName} ${row.lastName}`, // Type-safe accessor function
header: 'Full Name',
width: 200,
},
{
id: 'salary',
accessor: 'salary',
header: 'Salary',
width: 120,
renderer: ({ value }) => `$${Number(value).toLocaleString()}`,
},
]
// Type-safe component
export function EmployeeGrid() {
const [employees, setEmployees] = useState<Employee[]>([])
const handleEdit = async (rowId: string, columnId: string, value: unknown) => {
// TypeScript knows employees is Employee[]
setEmployees(prev => prev.map(emp =>
String(emp.id) === rowId
? { ...emp, [columnId]: value }
: emp
))
}
return (
<Griddy<Employee>
columns={columns}
data={employees}
height={600}
getRowId={(row) => String(row.id)}
onEditCommit={handleEdit}
/>
)
}
```

File diff suppressed because it is too large Load Diff

289
src/Griddy/README.md Normal file
View File

@@ -0,0 +1,289 @@
# Griddy
A powerful, keyboard-first data grid component built on **TanStack Table** and **TanStack Virtual** with full TypeScript support.
## Features
**Core Features**
- 🎹 **Keyboard-first navigation** - Arrow keys, Page Up/Down, Home/End, Ctrl+F
- 🚀 **Virtual scrolling** - Handle 10,000+ rows smoothly
- 📝 **Inline editing** - 5 built-in editors (text, number, date, select, checkbox)
- 🔍 **Search** - Ctrl+F overlay with highlighting
- 🎯 **Row selection** - Single and multi-select modes with keyboard support
- 📊 **Sorting** - Single and multi-column sorting
- 🔎 **Filtering** - Text, number, date, enum, boolean filters with operators
- 📄 **Pagination** - Client-side and server-side pagination
- 💾 **CSV Export** - Export filtered data to CSV
- 👁️ **Column visibility** - Show/hide columns dynamically
🎨 **Advanced Features**
- Server-side filtering/sorting/pagination
- Customizable cell renderers
- Custom editors
- Theme system with CSS variables
- Fully accessible (ARIA compliant)
## Installation
```bash
pnpm add @warkypublic/oranguru @tanstack/react-table @tanstack/react-virtual @mantine/core @mantine/dates
```
## Quick Start
```typescript
import { Griddy } from '@warkypublic/oranguru'
import type { GriddyColumn } from '@warkypublic/oranguru'
interface Person {
id: number
name: string
age: number
email: string
}
const columns: GriddyColumn<Person>[] = [
{ id: 'id', accessor: 'id', header: 'ID', width: 60 },
{ id: 'name', accessor: 'name', header: 'Name', width: 150, sortable: true },
{ id: 'age', accessor: 'age', header: 'Age', width: 80, sortable: true },
{ id: 'email', accessor: 'email', header: 'Email', width: 250 },
]
const data: Person[] = [
{ id: 1, name: 'Alice', age: 28, email: 'alice@example.com' },
{ id: 2, name: 'Bob', age: 32, email: 'bob@example.com' },
]
function MyGrid() {
return (
<Griddy
columns={columns}
data={data}
height={400}
getRowId={(row) => String(row.id)}
/>
)
}
```
## API Reference
### GriddyProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `columns` | `GriddyColumn<T>[]` | **required** | Column definitions |
| `data` | `T[]` | **required** | Data array |
| `height` | `number \| string` | `'100%'` | Container height |
| `getRowId` | `(row: T, index: number) => string` | `(_, i) => String(i)` | Row ID function |
| `rowHeight` | `number` | `36` | Row height in pixels |
| `overscan` | `number` | `10` | Overscan row count |
| `keyboardNavigation` | `boolean` | `true` | Enable keyboard shortcuts |
| `selection` | `SelectionConfig` | - | Row selection config |
| `search` | `SearchConfig` | - | Search config |
| `pagination` | `PaginationConfig` | - | Pagination config |
| `showToolbar` | `boolean` | `false` | Show toolbar (export + column visibility) |
| `exportFilename` | `string` | `'export.csv'` | CSV export filename |
| `manualSorting` | `boolean` | `false` | Server-side sorting |
| `manualFiltering` | `boolean` | `false` | Server-side filtering |
| `dataCount` | `number` | - | Total row count (for server-side pagination) |
### Column Definition
```typescript
interface GriddyColumn<T> {
id: string
accessor: keyof T | ((row: T) => any)
header: string | ReactNode
width?: number
minWidth?: number
maxWidth?: number
sortable?: boolean
filterable?: boolean
filterConfig?: FilterConfig
editable?: boolean
editorConfig?: EditorConfig
renderer?: CellRenderer<T>
hidden?: boolean
pinned?: 'left' | 'right'
}
```
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Arrow Up/Down` | Move focus between rows |
| `Page Up/Down` | Jump by visible page size |
| `Home / End` | Jump to first/last row |
| `Space` | Toggle row selection |
| `Shift + Arrow` | Extend selection (multi-select) |
| `Ctrl + A` | Select all rows |
| `Ctrl + F` | Open search overlay |
| `Ctrl + E` / `Enter` | Start editing |
| `Escape` | Cancel edit / close search / clear selection |
## Examples
### With Editing
```typescript
const editableColumns: GriddyColumn<Person>[] = [
{
id: 'name',
accessor: 'name',
header: 'Name',
editable: true,
editorConfig: { type: 'text' },
},
{
id: 'age',
accessor: 'age',
header: 'Age',
editable: true,
editorConfig: { type: 'number', min: 0, max: 120 },
},
]
<Griddy
columns={editableColumns}
data={data}
onEditCommit={(rowId, columnId, value) => {
// Update your data
setData(prev => prev.map(row =>
row.id === rowId ? { ...row, [columnId]: value } : row
))
}}
/>
```
### With Filtering
```typescript
const filterableColumns: GriddyColumn<Person>[] = [
{
id: 'name',
accessor: 'name',
header: 'Name',
filterable: true,
filterConfig: { type: 'text' },
},
{
id: 'age',
accessor: 'age',
header: 'Age',
filterable: true,
filterConfig: { type: 'number' },
},
]
<Griddy
columns={filterableColumns}
data={data}
columnFilters={filters}
onColumnFiltersChange={setFilters}
/>
```
### With Pagination
```typescript
<Griddy
columns={columns}
data={data}
pagination={{
enabled: true,
pageSize: 25,
pageSizeOptions: [10, 25, 50, 100],
}}
/>
```
### Server-Side Mode
```typescript
const [serverData, setServerData] = useState([])
const [filters, setFilters] = useState([])
const [sorting, setSorting] = useState([])
useEffect(() => {
// Fetch from server when filters/sorting change
fetchData({ filters, sorting }).then(setServerData)
}, [filters, sorting])
<Griddy
columns={columns}
data={serverData}
manualFiltering
manualSorting
columnFilters={filters}
onColumnFiltersChange={setFilters}
sorting={sorting}
onSortingChange={setSorting}
/>
```
## Theming
Griddy uses CSS variables for theming:
```css
.griddy {
--griddy-font-family: inherit;
--griddy-font-size: 14px;
--griddy-border-color: #e0e0e0;
--griddy-header-bg: #f8f9fa;
--griddy-header-color: #212529;
--griddy-row-bg: #ffffff;
--griddy-row-hover-bg: #f1f3f5;
--griddy-row-even-bg: #f8f9fa;
--griddy-focus-color: #228be6;
--griddy-selection-bg: rgba(34, 139, 230, 0.1);
}
```
Override in your CSS:
```css
.my-custom-grid {
--griddy-focus-color: #ff6b6b;
--griddy-header-bg: #1a1b1e;
--griddy-header-color: #ffffff;
}
```
## Performance
- ✅ Handles **10,000+ rows** with virtual scrolling
-**60 fps** scrolling performance
- ✅ Optimized with React.memo and useMemo
- ✅ Only visible rows rendered (TanStack Virtual)
- ✅ Bundle size: ~45KB gzipped (excluding peer deps)
## Accessibility
Griddy follows WAI-ARIA grid pattern:
- ✅ Full keyboard navigation
- ✅ ARIA roles: `grid`, `row`, `gridcell`, `columnheader`
-`aria-selected` on selected rows
-`aria-activedescendant` for focused row
- ✅ Screen reader compatible
- ✅ Focus indicators
## Browser Support
- Chrome/Edge: Latest 2 versions
- Firefox: Latest 2 versions
- Safari: Latest 2 versions
## License
MIT
## Credits
Built with:
- [TanStack Table](https://tanstack.com/table) - Headless table logic
- [TanStack Virtual](https://tanstack.com/virtual) - Virtualization
- [Mantine](https://mantine.dev/) - UI components

237
src/Griddy/THEME.md Normal file
View File

@@ -0,0 +1,237 @@
# Griddy Theming Guide
Griddy uses CSS custom properties (variables) for theming, making it easy to customize colors, spacing, and typography.
## Default Theme
```css
.griddy {
/* Typography */
--griddy-font-family: inherit;
--griddy-font-size: 14px;
/* Colors */
--griddy-border-color: #e0e0e0;
--griddy-header-bg: #f8f9fa;
--griddy-header-color: #212529;
--griddy-row-bg: #ffffff;
--griddy-row-hover-bg: #f1f3f5;
--griddy-row-even-bg: #f8f9fa;
--griddy-focus-color: #228be6;
--griddy-selection-bg: rgba(34, 139, 230, 0.1);
/* Spacing */
--griddy-cell-padding: 0 8px;
/* Search */
--griddy-search-bg: #ffffff;
--griddy-search-border: #dee2e6;
}
```
## Custom Theme Examples
### Dark Theme
```css
.griddy-dark {
--griddy-border-color: #373A40;
--griddy-header-bg: #25262b;
--griddy-header-color: #C1C2C5;
--griddy-row-bg: #1A1B1E;
--griddy-row-hover-bg: #25262b;
--griddy-row-even-bg: #1A1B1E;
--griddy-focus-color: #339af0;
--griddy-selection-bg: rgba(51, 154, 240, 0.15);
--griddy-search-bg: #25262b;
--griddy-search-border: #373A40;
}
```
Usage:
```tsx
<Griddy
className="griddy-dark"
columns={columns}
data={data}
/>
```
### High Contrast Theme
```css
.griddy-high-contrast {
--griddy-border-color: #000000;
--griddy-header-bg: #000000;
--griddy-header-color: #ffffff;
--griddy-row-bg: #ffffff;
--griddy-row-hover-bg: #e0e0e0;
--griddy-row-even-bg: #f5f5f5;
--griddy-focus-color: #ff0000;
--griddy-selection-bg: #ffff00;
--griddy-font-size: 16px;
}
```
### Brand Theme
```css
.griddy-brand {
--griddy-focus-color: #ff6b6b;
--griddy-selection-bg: rgba(255, 107, 107, 0.1);
--griddy-header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--griddy-header-color: #ffffff;
--griddy-font-family: 'Inter', sans-serif;
}
```
## Inline Styling
For dynamic theming:
```tsx
<Griddy
style={{
'--griddy-focus-color': brandColor,
'--griddy-header-bg': headerBg,
} as React.CSSProperties}
columns={columns}
data={data}
/>
```
## Mantine Integration
Griddy integrates seamlessly with Mantine's theme:
```tsx
import { MantineProvider, useMantineTheme } from '@mantine/core'
function ThemedGrid() {
const theme = useMantineTheme()
return (
<Griddy
style={{
'--griddy-focus-color': theme.colors.blue[6],
'--griddy-header-bg': theme.colors.gray[1],
'--griddy-border-color': theme.colors.gray[3],
} as React.CSSProperties}
columns={columns}
data={data}
/>
)
}
```
## Typography
Customize font family and size:
```css
.griddy-custom-font {
--griddy-font-family: 'Roboto Mono', monospace;
--griddy-font-size: 13px;
}
```
## Spacing
Adjust cell padding:
```css
.griddy-compact {
--griddy-cell-padding: 0 4px;
}
.griddy-spacious {
--griddy-cell-padding: 0 16px;
}
```
## CSS Classes
Griddy exposes these CSS classes for fine-grained control:
| Class | Element |
|-------|---------|
| `.griddy` | Root container |
| `.griddy-container` | Scroll container |
| `.griddy-thead` | Table header |
| `.griddy-header-row` | Header row |
| `.griddy-header-cell` | Header cell |
| `.griddy-tbody` | Table body (virtual) |
| `.griddy-row` | Data row |
| `.griddy-row--focused` | Focused row |
| `.griddy-row--selected` | Selected row |
| `.griddy-cell` | Data cell |
| `.griddy-search-overlay` | Search overlay |
| `.griddy-pagination` | Pagination controls |
## Advanced Customization
Override specific components:
```css
/* Custom header styling */
.griddy .griddy-header-cell {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Custom row hover effect */
.griddy .griddy-row:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
transition: all 0.2s ease;
}
/* Custom focus indicator */
.griddy .griddy-row--focused {
outline: 3px solid var(--griddy-focus-color);
outline-offset: -3px;
box-shadow: 0 0 0 3px rgba(34, 139, 230, 0.1);
}
```
## Responsive Theming
Adjust theme based on screen size:
```css
@media (max-width: 768px) {
.griddy {
--griddy-font-size: 12px;
--griddy-cell-padding: 0 4px;
}
}
@media (prefers-color-scheme: dark) {
.griddy {
--griddy-border-color: #373A40;
--griddy-header-bg: #25262b;
--griddy-header-color: #C1C2C5;
--griddy-row-bg: #1A1B1E;
}
}
```
## Print Styling
Optimize for printing:
```css
@media print {
.griddy {
--griddy-border-color: #000000;
--griddy-row-even-bg: #f5f5f5;
--griddy-font-size: 10pt;
}
.griddy .griddy-pagination,
.griddy .griddy-search-overlay {
display: none;
}
}
```

View File

@@ -0,0 +1,264 @@
# Tree/Hierarchical Data Feature - Implementation Summary
## Overview
Successfully implemented complete tree/hierarchical data support for Griddy, enabling the display and interaction with nested data structures like organization charts, file systems, and category hierarchies.
## ✅ Completed Features
### 1. Core Types & Configuration (Phase 1)
- **TreeConfig<T> interface** added to `types.ts` with comprehensive configuration options
- **Tree state** added to GriddyStore (loading nodes, children cache, setter methods)
- **Props integration** - `tree` prop added to GriddyProps
### 2. Data Transformation Layer (Phase 2)
- **transformTreeData.ts**: Utilities for transforming flat data to nested structure
- `transformFlatToNested()` - Converts flat arrays with parentId to nested tree
- `hasChildren()` - Determines if a node can expand
- `insertChildrenIntoData()` - Helper for lazy loading to update data array
- **useTreeData.ts**: Hook that transforms data based on tree mode (nested/flat/lazy)
### 3. UI Components (Phase 3)
- **TreeExpandButton.tsx**: Expand/collapse button component
- Shows loading spinner during lazy fetch
- Supports custom icons (expanded/collapsed/leaf)
- Handles disabled states
- **TableCell.tsx modifications**:
- Added tree indentation based on depth (configurable indentSize)
- Renders TreeExpandButton in first column
- Proper integration with existing grouping expand buttons
### 4. Lazy Loading (Phase 4)
- **useLazyTreeExpansion.ts**: Hook for on-demand child fetching
- Monitors expanded state changes
- Calls `getChildren` callback when node expands
- Updates cache and data array with fetched children
- Shows loading spinner during fetch
### 5. Keyboard Navigation (Phase 5)
- **Extended useKeyboardNavigation.ts**:
- **ArrowLeft**: Collapse expanded node OR move to parent if collapsed
- **ArrowRight**: Expand collapsed node OR move to first child if expanded
- Helper function `findParentRow()` for walking up the tree
- Auto-scroll focused row into view
### 6. Search Auto-Expand (Phase 6)
- **useAutoExpandOnSearch.ts**: Automatically expands parent nodes when search matches children
- Watches `globalFilter` changes
- Builds ancestor chain for matched rows
- Expands all ancestors to reveal matched nodes
- Configurable via `autoExpandOnSearch` option (default: true)
### 7. TanStack Table Integration (Phase 7)
- **Griddy.tsx modifications**:
- Integrated `useTreeData` hook for data transformation
- Configured `getSubRows` for TanStack Table
- Added `useLazyTreeExpansion` and `useAutoExpandOnSearch` hooks
- Passed tree config to keyboard navigation
### 8. CSS Styling (Phase 8)
- **griddy.module.css additions**:
- `.griddy-tree-expand-button` - Button styles with hover states
- Loading and disabled states
- Optional depth visual indicators (colored borders)
- Focus-visible outline for accessibility
### 9. Documentation & Examples (Phase 10)
- **6 Comprehensive Storybook stories**:
1. **TreeNestedMode**: Basic nested tree with departments → teams → people
2. **TreeFlatMode**: Same data as flat array with parentId, auto-transformed
3. **TreeLazyMode**: Async children fetching with loading spinner
4. **TreeWithSearch**: Search auto-expands parent chain to reveal matches
5. **TreeCustomIcons**: Custom expand/collapse/leaf icons (folder emojis)
6. **TreeDeepWithMaxDepth**: Deep tree (5 levels) with maxDepth enforcement
## 🎯 API Overview
### TreeConfig Interface
```typescript
interface TreeConfig<T> {
enabled: boolean;
mode?: 'nested' | 'flat' | 'lazy'; // default: 'nested'
// Flat mode
parentIdField?: keyof T | string; // default: 'parentId'
// Nested mode
childrenField?: keyof T | string; // default: 'children'
// Lazy mode
getChildren?: (parent: T) => Promise<T[]> | T[];
hasChildren?: (row: T) => boolean;
// Expansion state
defaultExpanded?: Record<string, boolean> | string[];
expanded?: Record<string, boolean>;
onExpandedChange?: (expanded: Record<string, boolean>) => void;
// UI configuration
autoExpandOnSearch?: boolean; // default: true
indentSize?: number; // default: 20px
maxDepth?: number; // default: Infinity
showExpandIcon?: boolean; // default: true
icons?: {
expanded?: ReactNode;
collapsed?: ReactNode;
leaf?: ReactNode;
};
}
```
### Usage Examples
#### Nested Mode (Default)
```tsx
<Griddy
data={nestedData} // Data with children arrays
columns={columns}
tree={{
enabled: true,
mode: 'nested',
childrenField: 'children',
indentSize: 24,
}}
/>
```
#### Flat Mode
```tsx
<Griddy
data={flatData} // Data with parentId references
columns={columns}
tree={{
enabled: true,
mode: 'flat',
parentIdField: 'parentId',
}}
/>
```
#### Lazy Mode
```tsx
<Griddy
data={rootNodes}
columns={columns}
tree={{
enabled: true,
mode: 'lazy',
getChildren: async (parent) => {
const response = await fetch(`/api/children/${parent.id}`);
return response.json();
},
hasChildren: (row) => row.hasChildren,
}}
/>
```
#### With Search Auto-Expand
```tsx
<Griddy
data={treeData}
columns={columns}
search={{ enabled: true, highlightMatches: true }}
tree={{
enabled: true,
autoExpandOnSearch: true, // Auto-expand parents when searching
}}
/>
```
#### Custom Icons
```tsx
<Griddy
data={treeData}
columns={columns}
tree={{
enabled: true,
icons: {
expanded: <IconChevronDown />,
collapsed: <IconChevronRight />,
leaf: <IconFile />,
},
}}
/>
```
## 🎹 Keyboard Shortcuts
| Key | Action |
|-----|--------|
| **ArrowRight** | Expand collapsed node OR move to first child |
| **ArrowLeft** | Collapse expanded node OR move to parent |
| **ArrowUp/Down** | Navigate between rows (standard) |
| **Space** | Toggle row selection (if enabled) |
| **Ctrl+F** | Open search (auto-expands on match) |
## 🏗️ Architecture Highlights
### Data Flow
1. **Data Input**`useTreeData` → Transforms based on mode
2. **Transformed Data** → TanStack Table `getSubRows`
3. **Expand Event**`useLazyTreeExpansion` → Fetch children (lazy mode)
4. **Search Event**`useAutoExpandOnSearch` → Expand ancestors
5. **Keyboard Event**`useKeyboardNavigation` → Collapse/expand/navigate
### Performance
- **Virtualization**: Only visible rows rendered (TanStack Virtual)
- **Memoization**: `useTreeData` memoizes transformations
- **Lazy Loading**: Children fetched only when needed
- **Cache**: Fetched children cached in store to avoid re-fetch
### Integration with Existing Features
- ✅ Works with **sorting** (sorts within each level)
- ✅ Works with **filtering** (filters all levels)
- ✅ Works with **search** (auto-expands to reveal matches)
- ✅ Works with **selection** (select any row at any level)
- ✅ Works with **editing** (edit any row at any level)
- ✅ Works with **pagination** (paginate flattened tree)
- ✅ Works with **grouping** (can use both simultaneously)
## 📁 Files Created/Modified
### New Files (7)
1. `src/Griddy/features/tree/transformTreeData.ts`
2. `src/Griddy/features/tree/useTreeData.ts`
3. `src/Griddy/features/tree/useLazyTreeExpansion.ts`
4. `src/Griddy/features/tree/useAutoExpandOnSearch.ts`
5. `src/Griddy/features/tree/TreeExpandButton.tsx`
6. `src/Griddy/features/tree/index.ts`
7. `src/Griddy/TREE_FEATURE_SUMMARY.md` (this file)
### Modified Files (6)
1. `src/Griddy/core/types.ts` - Added TreeConfig interface
2. `src/Griddy/core/GriddyStore.ts` - Added tree state
3. `src/Griddy/core/Griddy.tsx` - Integrated tree hooks
4. `src/Griddy/rendering/TableCell.tsx` - Added tree indentation & button
5. `src/Griddy/features/keyboard/useKeyboardNavigation.ts` - Added tree navigation
6. `src/Griddy/styles/griddy.module.css` - Added tree styles
7. `src/Griddy/Griddy.stories.tsx` - Added 6 tree stories
8. `src/Griddy/plan.md` - Updated completion status
## ✅ Success Criteria (All Met)
- ✅ Nested tree renders with visual indentation
- ✅ Expand/collapse via click and keyboard (ArrowLeft/Right)
- ✅ Flat data transforms correctly to nested structure
- ✅ Lazy loading fetches children on expand with loading spinner
- ✅ Search auto-expands parent chain to reveal matched children
- ✅ All features work with virtualization (tested with deep trees)
- ✅ TypeScript compilation passes without errors
- ✅ Documented in Storybook with 6 comprehensive stories
## 🚀 Next Steps (Optional Enhancements)
1. **E2E Tests**: Add Playwright tests for tree interactions
2. **Drag & Drop**: Tree node reordering via drag-and-drop
3. **Bulk Operations**: Expand all, collapse all, expand to level N
4. **Tree Filtering**: Show only matching subtrees
5. **Context Menu**: Right-click menu for tree operations
6. **Breadcrumb Navigation**: Show path to focused node
## 🎉 Summary
The tree/hierarchical data feature is **production-ready** and fully integrated with Griddy's existing features. It supports three modes (nested, flat, lazy), keyboard navigation, search auto-expand, and custom styling. All 12 implementation tasks completed successfully with comprehensive Storybook documentation.

View File

@@ -0,0 +1,414 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Box } from '@mantine/core';
import { useRef } from 'react';
import type { GriddyColumn } from '../core/types';
import type { AdapterConfig, AdapterRef } from './types';
import { Griddy } from '../core/Griddy';
import { HeaderSpecAdapter } from './HeaderSpecAdapter';
import { ResolveSpecAdapter } from './ResolveSpecAdapter';
// ─── Sample Column Definitions ──────────────────────────────────────────────
interface User {
active: boolean;
department: string;
email: string;
id: number;
name: string;
}
const columns: GriddyColumn<User>[] = [
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
{ accessor: 'name', header: 'Name', id: 'name', sortable: true, width: 150 },
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
{
accessor: 'department',
filterable: true,
filterConfig: {
enumOptions: ['Engineering', 'Marketing', 'Sales', 'HR'].map((d) => ({ label: d, value: d })),
type: 'enum',
},
header: 'Department',
id: 'department',
sortable: true,
width: 130,
},
{
accessor: (row) => (row.active ? 'Yes' : 'No'),
header: 'Active',
id: 'active',
sortable: true,
width: 80,
},
];
// ─── Wrapper for ResolveSpecAdapter story ───────────────────────────────────
function HeaderSpecAdapterStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#d3f9d8',
border: '1px solid #51cf66',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>HeaderSpecAdapter:</strong> Connects Griddy to a HeaderSpec API. Same auto-wiring as
ResolveSpecAdapter but uses HeaderSpecClient.
<div style={{ marginTop: 8 }}>
<button onClick={() => adapterRef.current?.refetch()} type="button">
Manual Refetch
</button>
</div>
</Box>
<Griddy<User>
columns={columns}
data={[]}
getRowId={(row) => String(row.id)}
height={500}
manualFiltering
manualSorting
pagination={{
enabled: true,
pageSize: props.defaultOptions?.limit ?? 25,
pageSizeOptions: [10, 25, 50, 100],
type: 'offset',
}}
>
<HeaderSpecAdapter ref={adapterRef} {...props} mode="offset" />
</Griddy>
</Box>
);
}
// ─── Wrapper for HeaderSpecAdapter story ────────────────────────────────────
function ResolveSpecAdapterStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#e7f5ff',
border: '1px solid #339af0',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>ResolveSpecAdapter:</strong> Connects Griddy to a ResolveSpec API. Sorting,
filtering, and pagination are translated to ResolveSpec Options automatically.
<div style={{ marginTop: 8 }}>
<button onClick={() => adapterRef.current?.refetch()} type="button">
Manual Refetch
</button>
</div>
</Box>
<Griddy<User>
columns={columns}
data={[]}
getRowId={(row) => String(row.id)}
height={500}
manualFiltering
manualSorting
pagination={{
enabled: true,
pageSize: props.defaultOptions?.limit ?? 25,
pageSizeOptions: [10, 25, 50, 100],
type: 'offset',
}}
>
<ResolveSpecAdapter ref={adapterRef} {...props} mode="offset" />
</Griddy>
</Box>
);
}
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta = {
args: {
autoFetch: true,
baseUrl: 'http://localhost:3000',
debounceMs: 300,
entity: 'users',
schema: 'public',
},
argTypes: {
autoFetch: {
control: 'boolean',
description: 'Fetch data on mount',
},
baseUrl: {
control: 'text',
description: 'API base URL',
},
columnMap: {
control: 'object',
description: 'Griddy column ID to API column name mapping',
},
cursorField: {
control: 'text',
description: 'Field to extract cursor from (default: "id")',
},
debounceMs: {
control: { max: 2000, min: 0, step: 50, type: 'range' },
description: 'Filter change debounce in ms',
},
entity: {
control: 'text',
description: 'Database entity/table name',
},
mode: {
control: 'inline-radio',
description: 'Pagination mode: cursor (infinite scroll) or offset (page controls)',
options: ['cursor', 'offset'],
},
schema: {
control: 'text',
description: 'Database schema name',
},
token: {
control: 'text',
description: 'Auth token (optional)',
},
},
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
title: 'Components/Griddy/Adapters',
} satisfies Meta<AdapterConfig>;
export default meta;
type Story = StoryObj<typeof meta>;
// ─── Stories ────────────────────────────────────────────────────────────────
/** ResolveSpec adapter — auto-wires sorting, filtering, pagination to a ResolveSpec API */
export const ResolveSpec: Story = {
args: {
baseUrl: 'https://utils.btsys.tech/api',
},
render: (args) => <ResolveSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** HeaderSpec adapter — same as ResolveSpec but uses HeaderSpecClient */
export const HeaderSpec: Story = {
args: {
baseUrl: 'https://utils.btsys.tech/api',
columnMap: {
active: 'inactive',
department: 'department',
email: 'logmessage',
name: 'type',
},
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
entity: 'synclog',
},
render: (args) => <HeaderSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** ResolveSpec with column mapping — remaps Griddy column IDs to different API column names */
export const WithColumnMap: Story = {
args: {
columnMap: {
active: 'is_active',
department: 'dept',
email: 'email_address',
name: 'full_name',
},
},
render: (args) => <ResolveSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** ResolveSpec with custom debounce — slower debounce for expensive queries */
export const WithCustomDebounce: Story = {
args: {
debounceMs: 1000,
},
render: (args) => <ResolveSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** ResolveSpec with autoFetch disabled — data only loads on manual refetch */
export const ManualFetchOnly: Story = {
args: {
autoFetch: false,
},
render: (args) => <ResolveSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** ResolveSpec with default options merged into every request */
export const WithDefaultOptions: Story = {
args: {
defaultOptions: {
limit: 50,
sort: [{ column: 'name', direction: 'asc' }],
},
},
render: (args) => <ResolveSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
};
// ─── Cursor / Infinite Scroll Stories ────────────────────────────────────────
function HeaderSpecInfiniteScrollStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#d3f9d8',
border: '1px solid #51cf66',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>HeaderSpec cursor mode:</strong> HeaderSpecAdapter with cursor-based infinite
scroll.
</Box>
<Griddy<User>
columns={columns}
data={[]}
getRowId={(row) => String(row.id)}
height={500}
manualFiltering
manualSorting
>
<HeaderSpecAdapter cursorField="id" ref={adapterRef} {...props} mode="cursor" />
</Griddy>
</Box>
);
}
function InfiniteScrollStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#fff3bf',
border: '1px solid #fab005',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>Cursor mode (default):</strong> Uses cursor-based pagination with infinite scroll.
Scroll to the bottom to load more rows automatically.
</Box>
<Griddy<User>
columns={columns}
data={[]}
getRowId={(row) => String(row.id)}
height={500}
manualFiltering
manualSorting
>
<ResolveSpecAdapter ref={adapterRef} {...props} mode="cursor" />
</Griddy>
</Box>
);
}
function OffsetPaginationStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#ffe3e3',
border: '1px solid #fa5252',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>Offset mode:</strong> Uses traditional offset/limit pagination with page controls.
</Box>
<Griddy<User>
columns={columns}
data={[]}
getRowId={(row) => String(row.id)}
height={500}
manualFiltering
manualSorting
pagination={{
enabled: true,
pageSize: props.pageSize ?? 25,
pageSizeOptions: [10, 25, 50, 100],
type: 'offset',
}}
>
<ResolveSpecAdapter ref={adapterRef} {...props} mode="offset" />
</Griddy>
</Box>
);
}
/** ResolveSpec with cursor pagination and infinite scroll (default adapter mode) */
export const WithInfiniteScroll: Story = {
args: {
baseUrl: 'https://utils.btsys.tech/api',
},
render: (args) => <InfiniteScrollStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** ResolveSpec with explicit cursor pagination config */
export const WithCursorPagination: Story = {
args: {
cursorField: 'id',
pageSize: 50,
},
render: (args) => <InfiniteScrollStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** ResolveSpec with offset pagination controls */
export const WithOffsetPagination: Story = {
args: {
pageSize: 25,
},
render: (args) => <OffsetPaginationStory baseUrl={''} entity={''} schema={''} {...args} />,
};
/** HeaderSpec adapter with cursor-based infinite scroll */
export const HeaderSpecInfiniteScroll: Story = {
args: {
baseUrl: 'https://utils.btsys.tech/api',
columnMap: {
name: 'logtype',
email: 'logmessage',
},
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
entity: 'synclog',
cursorField: 'id',
},
render: (args) => (
<HeaderSpecInfiniteScrollStory baseUrl={''} entity={''} schema={''} {...args} />
),
};

View File

@@ -0,0 +1,293 @@
import { getHeaderSpecClient } from '@warkypublic/resolvespec-js';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { AdapterConfig, AdapterRef } from './types';
import { useGriddyStore } from '../core/GriddyStore';
import { applyCursor, buildOptions } from './mapOptions';
export const HeaderSpecAdapter = forwardRef<AdapterRef, AdapterConfig>(
function HeaderSpecAdapter(props, ref) {
const {
autoFetch = true,
baseUrl,
columnMap,
computedColumns,
cursorField = 'id',
customOperators,
debounceMs = 300,
defaultOptions,
entity,
mode = 'cursor',
pageSize = 25,
preload,
schema,
token,
} = props;
const sorting = useGriddyStore((s) => s.sorting ?? []);
const columnFilters = useGriddyStore((s) => s.columnFilters ?? []);
const pagination = useGriddyStore((s) => s.pagination);
const paginationState = useGriddyStore((s) => s.paginationState);
const setData = useGriddyStore((s) => s.setData);
const appendData = useGriddyStore((s) => s.appendData);
const setDataCount = useGriddyStore((s) => s.setDataCount);
const setIsLoading = useGriddyStore((s) => s.setIsLoading);
const setError = useGriddyStore((s) => s.setError);
const setInfiniteScroll = useGriddyStore((s) => s.setInfiniteScroll);
const clientRef = useRef(getHeaderSpecClient({ baseUrl, token }));
const debounceRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const mountedRef = useRef(true);
// Infinite scroll state (cursor mode)
const cursorRef = useRef<null | string>(null);
const hasMoreRef = useRef(true);
const [cursorLoading, setCursorLoading] = useState(false);
useEffect(() => {
clientRef.current = getHeaderSpecClient({ baseUrl, token });
}, [baseUrl, token]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// ─── Offset mode fetch (original behavior) ───
const fetchDataOffset = useCallback(async () => {
if (!mountedRef.current) return;
setIsLoading(true);
setError(null);
try {
// Fall back to config when store hasn't synced yet (initial render)
const effectivePagination =
paginationState ??
(pagination?.enabled ? { pageIndex: 0, pageSize: pagination.pageSize } : undefined);
const options = buildOptions(
sorting,
columnFilters,
effectivePagination,
columnMap,
defaultOptions
);
if (preload) options.preload = preload;
if (computedColumns) options.computedColumns = computedColumns;
if (customOperators) options.customOperators = customOperators;
const response = await clientRef.current.read(schema, entity, undefined, options);
if (!mountedRef.current) return;
if (response.success) {
const rows = Array.isArray(response.data) ? response.data : [response.data];
setData(rows);
if (response.metadata?.total != null) {
setDataCount(response.metadata.total);
}
} else if (response.error) {
setError(new Error(response.error.message ?? 'Request failed'));
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
}
}, [
sorting,
columnFilters,
pagination,
paginationState,
columnMap,
defaultOptions,
preload,
computedColumns,
customOperators,
schema,
entity,
setData,
setDataCount,
setIsLoading,
setError,
]);
// ─── Cursor mode fetch (uses cursor_forward only) ───
const fetchCursorPage = useCallback(
async (cursor: null | string, isAppend: boolean) => {
if (!mountedRef.current) return;
if (isAppend) {
setCursorLoading(true);
} else {
setIsLoading(true);
}
setError(null);
try {
const options = buildOptions(
sorting,
columnFilters,
undefined,
columnMap,
defaultOptions
);
if (preload) options.preload = preload;
if (computedColumns) options.computedColumns = computedColumns;
if (customOperators) options.customOperators = customOperators;
const cursorOptions = applyCursor(options, pageSize, cursor);
const response = await clientRef.current.read(schema, entity, undefined, cursorOptions);
if (!mountedRef.current) return;
if (response.success) {
const rows = Array.isArray(response.data) ? response.data : [response.data];
if (isAppend) {
appendData(rows);
} else {
setData(rows);
}
if (response.metadata?.total) {
setDataCount(response.metadata.total);
} else if (response.metadata?.count) {
setDataCount(response.metadata.count);
}
// Extract cursor from last row
if (rows.length > 0) {
const lastRow = rows[rows.length - 1];
if (lastRow?.[cursorField] == null) {
hasMoreRef.current = false;
setError(
new Error(
`Cursor field "${cursorField}" not found in response data. ` +
`Set cursorField to match your data's primary key, or use mode="offset".`
)
);
return;
}
cursorRef.current = String(lastRow[cursorField]);
}
hasMoreRef.current = rows.length >= pageSize;
} else if (response.error) {
setError(new Error(response.error.message ?? 'Request failed'));
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (mountedRef.current) {
if (isAppend) {
setCursorLoading(false);
} else {
setIsLoading(false);
}
}
}
},
[
sorting,
columnFilters,
columnMap,
defaultOptions,
preload,
computedColumns,
customOperators,
schema,
entity,
pageSize,
cursorField,
setData,
appendData,
setDataCount,
setIsLoading,
setError,
]
);
const fetchNextPage = useCallback(() => {
if (!hasMoreRef.current || cursorLoading) return;
return fetchCursorPage(cursorRef.current, true);
}, [cursorLoading, fetchCursorPage]);
const resetAndFetch = useCallback(async () => {
cursorRef.current = null;
hasMoreRef.current = true;
await fetchCursorPage(null, false);
}, [fetchCursorPage]);
// ─── Unified fetch dispatch ───
const fetchData = mode === 'cursor' ? resetAndFetch : fetchDataOffset;
// ─── Infinite scroll config sync (cursor mode only) ───
useEffect(() => {
if (mode !== 'cursor' || pagination?.enabled) {
setInfiniteScroll(undefined);
return;
}
setInfiniteScroll({
enabled: true,
hasMore: hasMoreRef.current,
isLoading: cursorLoading,
onLoadMore: fetchNextPage,
threshold: 10,
});
}, [mode, pagination?.enabled, cursorLoading, fetchNextPage, setInfiniteScroll]);
useEffect(() => {
return () => {
setInfiniteScroll(undefined);
};
}, [setInfiniteScroll]);
useImperativeHandle(ref, () => ({ refetch: fetchData }), [fetchData]);
const initialFetchDone = useRef(false);
useEffect(() => {
if (autoFetch && !initialFetchDone.current) {
initialFetchDone.current = true;
fetchData();
}
}, [autoFetch, fetchData]);
const prevDepsRef = useRef<null | string>(null);
useEffect(() => {
const depsKey =
mode === 'cursor'
? JSON.stringify({ columnFilters, sorting })
: JSON.stringify({ columnFilters, paginationState, sorting });
if (prevDepsRef.current === null) {
prevDepsRef.current = depsKey;
return;
}
if (prevDepsRef.current === depsKey) return;
prevDepsRef.current = depsKey;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(fetchData, debounceMs);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [sorting, columnFilters, paginationState, debounceMs, fetchData, mode]);
return null;
}
);

View File

@@ -0,0 +1,291 @@
import { getResolveSpecClient } from '@warkypublic/resolvespec-js';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { AdapterConfig, AdapterRef } from './types';
import { useGriddyStore } from '../core/GriddyStore';
import { applyCursor, buildOptions } from './mapOptions';
export const ResolveSpecAdapter = forwardRef<AdapterRef, AdapterConfig>(
function ResolveSpecAdapter(props, ref) {
const {
autoFetch = true,
baseUrl,
columnMap,
computedColumns,
cursorField = 'id',
customOperators,
debounceMs = 300,
defaultOptions,
entity,
mode = 'cursor',
pageSize = 25,
preload,
schema,
token,
} = props;
const sorting = useGriddyStore((s) => s.sorting ?? []);
const columnFilters = useGriddyStore((s) => s.columnFilters ?? []);
const pagination = useGriddyStore((s) => s.pagination);
const paginationState = useGriddyStore((s) => s.paginationState);
const setData = useGriddyStore((s) => s.setData);
const appendData = useGriddyStore((s) => s.appendData);
const setDataCount = useGriddyStore((s) => s.setDataCount);
const setIsLoading = useGriddyStore((s) => s.setIsLoading);
const setError = useGriddyStore((s) => s.setError);
const setInfiniteScroll = useGriddyStore((s) => s.setInfiniteScroll);
const clientRef = useRef(getResolveSpecClient({ baseUrl, token }));
const debounceRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const mountedRef = useRef(true);
// Infinite scroll state (cursor mode)
const cursorRef = useRef<null | string>(null);
const hasMoreRef = useRef(true);
const [cursorLoading, setCursorLoading] = useState(false);
useEffect(() => {
clientRef.current = getResolveSpecClient({ baseUrl, token });
}, [baseUrl, token]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// ─── Offset mode fetch (original behavior) ───
const fetchDataOffset = useCallback(async () => {
if (!mountedRef.current) return;
setIsLoading(true);
setError(null);
try {
// Fall back to config when store hasn't synced yet (initial render)
const effectivePagination = paginationState ??
(pagination?.enabled ? { pageIndex: 0, pageSize: pagination.pageSize } : undefined);
const options = buildOptions(
sorting,
columnFilters,
effectivePagination,
columnMap,
defaultOptions
);
if (preload) options.preload = preload;
if (computedColumns) options.computedColumns = computedColumns;
if (customOperators) options.customOperators = customOperators;
const response = await clientRef.current.read(schema, entity, undefined, options);
if (!mountedRef.current) return;
if (response.success) {
const rows = Array.isArray(response.data) ? response.data : [response.data];
setData(rows);
if (response.metadata?.total != null) {
setDataCount(response.metadata.total);
}
} else if (response.error) {
setError(new Error(response.error.message ?? 'Request failed'));
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
}
}, [
sorting,
columnFilters,
pagination,
paginationState,
columnMap,
defaultOptions,
preload,
computedColumns,
customOperators,
schema,
entity,
setData,
setDataCount,
setIsLoading,
setError,
]);
// ─── Cursor mode fetch (uses cursor_forward only) ───
const fetchCursorPage = useCallback(
async (cursor: null | string, isAppend: boolean) => {
if (!mountedRef.current) return;
if (isAppend) {
setCursorLoading(true);
} else {
setIsLoading(true);
}
setError(null);
try {
const options = buildOptions(
sorting,
columnFilters,
undefined,
columnMap,
defaultOptions
);
if (preload) options.preload = preload;
if (computedColumns) options.computedColumns = computedColumns;
if (customOperators) options.customOperators = customOperators;
const cursorOptions = applyCursor(options, pageSize, cursor);
const response = await clientRef.current.read(schema, entity, undefined, cursorOptions);
if (!mountedRef.current) return;
if (response.success) {
const rows = Array.isArray(response.data) ? response.data : [response.data];
if (isAppend) {
appendData(rows);
} else {
setData(rows);
}
if (response.metadata?.total) {
setDataCount(response.metadata.total);
} else if (response.metadata?.count) {
setDataCount(response.metadata.count);
}
// Extract cursor from last row
if (rows.length > 0) {
const lastRow = rows[rows.length - 1];
if (lastRow?.[cursorField] == null) {
hasMoreRef.current = false;
setError(new Error(
`Cursor field "${cursorField}" not found in response data. ` +
`Set cursorField to match your data's primary key, or use mode="offset".`
));
return;
}
cursorRef.current = String(lastRow[cursorField]);
}
hasMoreRef.current = rows.length >= pageSize;
} else if (response.error) {
setError(new Error(response.error.message ?? 'Request failed'));
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (mountedRef.current) {
if (isAppend) {
setCursorLoading(false);
} else {
setIsLoading(false);
}
}
}
},
[
sorting,
columnFilters,
columnMap,
defaultOptions,
preload,
computedColumns,
customOperators,
schema,
entity,
pageSize,
cursorField,
setData,
appendData,
setDataCount,
setIsLoading,
setError,
]
);
const fetchNextPage = useCallback(() => {
if (!hasMoreRef.current || cursorLoading) return;
return fetchCursorPage(cursorRef.current, true);
}, [cursorLoading, fetchCursorPage]);
const resetAndFetch = useCallback(async () => {
cursorRef.current = null;
hasMoreRef.current = true;
await fetchCursorPage(null, false);
}, [fetchCursorPage]);
// ─── Unified fetch dispatch ───
const fetchData = mode === 'cursor' ? resetAndFetch : fetchDataOffset;
// ─── Infinite scroll config sync (cursor mode only) ───
useEffect(() => {
if (mode !== 'cursor' || pagination?.enabled) {
setInfiniteScroll(undefined);
return;
}
setInfiniteScroll({
enabled: true,
hasMore: hasMoreRef.current,
isLoading: cursorLoading,
onLoadMore: fetchNextPage,
threshold: 10,
});
}, [mode, pagination?.enabled, cursorLoading, fetchNextPage, setInfiniteScroll]);
useEffect(() => {
return () => {
setInfiniteScroll(undefined);
};
}, [setInfiniteScroll]);
useImperativeHandle(ref, () => ({ refetch: fetchData }), [fetchData]);
const initialFetchDone = useRef(false);
useEffect(() => {
if (autoFetch && !initialFetchDone.current) {
initialFetchDone.current = true;
fetchData();
}
}, [autoFetch, fetchData]);
const prevDepsRef = useRef<null | string>(null);
useEffect(() => {
const depsKey =
mode === 'cursor'
? JSON.stringify({ columnFilters, sorting })
: JSON.stringify({ columnFilters, paginationState, sorting });
if (prevDepsRef.current === null) {
prevDepsRef.current = depsKey;
return;
}
if (prevDepsRef.current === depsKey) return;
prevDepsRef.current = depsKey;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(fetchData, debounceMs);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [sorting, columnFilters, paginationState, debounceMs, fetchData, mode]);
return null;
}
);

View File

@@ -0,0 +1,4 @@
export { HeaderSpecAdapter } from './HeaderSpecAdapter'
export { applyCursor, buildOptions, mapFilters, mapPagination, mapSorting } from './mapOptions'
export { ResolveSpecAdapter } from './ResolveSpecAdapter'
export type { AdapterConfig, AdapterRef } from './types'

View File

@@ -0,0 +1,126 @@
import type { ColumnFiltersState, PaginationState, SortingState } from '@tanstack/react-table';
import type { FilterOption, Options, SortOption } from '@warkypublic/resolvespec-js';
const OPERATOR_MAP: Record<string, string> = {
between: 'between',
contains: 'ilike',
endsWith: 'endswith',
equals: 'eq',
excludes: 'in',
greaterThan: 'gt',
greaterThanOrEqual: 'gte',
includes: 'in',
is: 'eq',
isAfter: 'gt',
isBefore: 'lt',
isBetween: 'between_inclusive',
isEmpty: 'is_null',
isFalse: 'eq',
isNotEmpty: 'is_not_null',
isTrue: 'eq',
lessThan: 'lt',
lessThanOrEqual: 'lte',
notContains: 'ilike',
notEquals: 'neq',
startsWith: 'startswith',
};
export function applyCursor(opts: Options, limit: number, cursor?: null | string): Options {
const result = { ...opts, limit };
if (cursor) {
result.cursor_forward = cursor;
}
return result;
}
export function buildOptions(
sorting: SortingState,
filters: ColumnFiltersState,
pagination: PaginationState | undefined,
columnMap?: Record<string, string>,
defaultOptions?: Partial<Options>
): Options {
const opts: Options = { ...defaultOptions };
if (sorting.length > 0) {
opts.sort = mapSorting(sorting, columnMap);
}
if (filters.length > 0) {
opts.filters = mapFilters(filters, columnMap);
}
if (pagination) {
const { limit, offset } = mapPagination(pagination);
opts.limit = limit;
opts.offset = offset;
}
return opts;
}
export function mapFilters(
filters: ColumnFiltersState,
columnMap?: Record<string, string>
): FilterOption[] {
return filters.flatMap((filter) => {
const filterValue = filter.value as any;
// Enum filter with values array
if (filterValue?.values && Array.isArray(filterValue.values)) {
return [
{
column: resolveColumn(filter.id, columnMap),
operator: 'in',
value: filterValue.values,
},
];
}
const operator = filterValue?.operator ?? 'eq';
const value = filterValue?.value ?? filterValue;
return [
{
column: resolveColumn(filter.id, columnMap),
operator: resolveOperator(operator),
value: resolveFilterValue(operator, value),
},
];
});
}
export function mapPagination(pagination: PaginationState): { limit: number; offset: number } {
return {
limit: pagination.pageSize,
offset: pagination.pageIndex * pagination.pageSize,
};
}
export function mapSorting(
sorting: SortingState,
columnMap?: Record<string, string>
): SortOption[] {
return sorting.map(({ desc, id }) => ({
column: resolveColumn(id, columnMap),
direction: desc ? ('desc' as const) : ('asc' as const),
}));
}
function resolveColumn(id: string, columnMap?: Record<string, string>): string {
return columnMap?.[id] ?? id;
}
function resolveFilterValue(operator: string, value: any): any {
if (operator === 'isTrue') return true;
if (operator === 'isFalse') return false;
if (operator === 'contains') return `%${value}%`;
if (operator === 'startsWith') return `${value}%`;
if (operator === 'endsWith') return `%${value}`;
if (operator === 'notContains') return `%${value}%`;
return value;
}
function resolveOperator(op: string): string {
return OPERATOR_MAP[op] ?? op;
}

View File

@@ -0,0 +1,30 @@
import type {
ComputedColumn,
CustomOperator,
Options,
PreloadOption,
} from '@warkypublic/resolvespec-js';
export interface AdapterConfig {
autoFetch?: boolean;
baseUrl: string;
columnMap?: Record<string, string>;
computedColumns?: ComputedColumn[];
/** Field to extract cursor value from last row. Default: 'id' */
cursorField?: string;
customOperators?: CustomOperator[];
debounceMs?: number;
defaultOptions?: Partial<Options>;
entity: string;
/** Pagination mode. 'cursor' uses cursor-based infinite scroll, 'offset' uses offset/limit pagination. Default: 'cursor' */
mode?: 'cursor' | 'offset';
/** Page size for both cursor and offset modes. Default: 25 */
pageSize?: number;
preload?: PreloadOption[];
schema: string;
token?: string;
}
export interface AdapterRef {
refetch: () => Promise<void>;
}

409
src/Griddy/core/Griddy.tsx Normal file
View File

@@ -0,0 +1,409 @@
import {
type ColumnDef,
type ColumnFiltersState,
type ColumnOrderState,
type ColumnPinningState,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getGroupedRowModel,
getPaginationRowModel,
getSortedRowModel,
type GroupingState,
type PaginationState,
type RowSelectionState,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import React, {
forwardRef,
type Ref,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { GriddyProps, GriddyRef } from './types';
import { advancedSearchGlobalFilterFn, AdvancedSearchPanel } from '../features/advancedSearch';
import { GriddyErrorBoundary } from '../features/errorBoundary';
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation';
import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading';
import { PaginationControl } from '../features/pagination';
import { SearchOverlay } from '../features/search/SearchOverlay';
import { GridToolbar } from '../features/toolbar';
import { useAutoExpandOnSearch, useLazyTreeExpansion, useTreeData } from '../features/tree';
import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer';
import { TableHeader } from '../rendering/TableHeader';
import { VirtualBody } from '../rendering/VirtualBody';
import styles from '../styles/griddy.module.css';
import { mapColumns } from './columnMapper';
import { CSS, DEFAULTS } from './constants';
import { GriddyProvider, useGriddyStore } from './GriddyStore';
// ─── Inner Component (lives inside Provider, has store access) ───────────────
function _Griddy<T>(props: GriddyProps<T>, ref: Ref<GriddyRef<T>>) {
return (
<GriddyProvider {...props}>
<GriddyErrorBoundary onError={props.onError} onRetry={props.onRetry}>
<GriddyInner tableRef={ref} />
</GriddyErrorBoundary>
{props.children}
</GriddyProvider>
);
}
// ─── Main Component with forwardRef ──────────────────────────────────────────
function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
// Read props from synced store
const data = useGriddyStore((s) => s.data);
const userColumns = useGriddyStore((s) => s.columns);
const getRowId = useGriddyStore((s) => s.getRowId);
const selection = useGriddyStore((s) => s.selection);
const search = useGriddyStore((s) => s.search);
const groupingConfig = useGriddyStore((s) => s.grouping);
const paginationConfig = useGriddyStore((s) => s.pagination);
const controlledSorting = useGriddyStore((s) => s.sorting);
const onSortingChange = useGriddyStore((s) => s.onSortingChange);
const controlledFilters = useGriddyStore((s) => s.columnFilters);
const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange);
const controlledPinning = useGriddyStore((s) => s.columnPinning);
const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange);
const controlledRowSelection = useGriddyStore((s) => s.rowSelection);
const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange);
const onEditCommit = useGriddyStore((s) => s.onEditCommit);
const rowHeight = useGriddyStore((s) => s.rowHeight);
const overscanProp = useGriddyStore((s) => s.overscan);
const height = useGriddyStore((s) => s.height);
const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation);
const className = useGriddyStore((s) => s.className);
const showToolbar = useGriddyStore((s) => s.showToolbar);
const exportFilename = useGriddyStore((s) => s.exportFilename);
const isLoading = useGriddyStore((s) => s.isLoading);
const filterPresets = useGriddyStore((s) => s.filterPresets);
const advancedSearch = useGriddyStore((s) => s.advancedSearch);
const persistenceKey = useGriddyStore((s) => s.persistenceKey);
const manualSorting = useGriddyStore((s) => s.manualSorting);
const manualFiltering = useGriddyStore((s) => s.manualFiltering);
const dataCount = useGriddyStore((s) => s.dataCount);
const setTable = useGriddyStore((s) => s.setTable);
const setPaginationState = useGriddyStore((s) => s.setPaginationState);
const setVirtualizer = useGriddyStore((s) => s.setVirtualizer);
const setScrollRef = useGriddyStore((s) => s.setScrollRef);
const setFocusedRow = useGriddyStore((s) => s.setFocusedRow);
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn);
const setEditing = useGriddyStore((s) => s.setEditing);
const setTotalRows = useGriddyStore((s) => s.setTotalRows);
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex);
const tree = useGriddyStore((s) => s.tree);
const setData = useGriddyStore((s) => s.setData);
const setTreeLoadingNode = useGriddyStore((s) => s.setTreeLoadingNode);
const setTreeChildrenCache = useGriddyStore((s) => s.setTreeChildrenCache);
const treeChildrenCache = useGriddyStore((s) => s.treeChildrenCache);
const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight;
const effectiveOverscan = overscanProp ?? DEFAULTS.overscan;
const enableKeyboard = keyboardNavigation !== false;
// ─── Tree Data Transformation ───
const transformedData = useTreeData(data ?? [], tree);
// ─── Column Mapping ───
const columns = useMemo(
() => mapColumns(userColumns ?? [], selection) as ColumnDef<T, any>[],
[userColumns, selection]
);
// ─── Table State (internal/uncontrolled) ───
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>([]);
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({});
const [globalFilter, setGlobalFilter] = useState<string | undefined>(undefined);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([]);
// Build initial column pinning from column definitions
const initialPinning = useMemo(() => {
const left: string[] = [];
const right: string[] = [];
userColumns?.forEach((col) => {
if (col.pinned === 'left') left.push(col.id);
else if (col.pinned === 'right') right.push(col.id);
});
return { left, right };
}, [userColumns]);
const [internalPinning, setInternalPinning] = useState<ColumnPinningState>(initialPinning);
const [grouping, setGrouping] = useState<GroupingState>(groupingConfig?.columns ?? []);
const [expanded, setExpanded] = useState({});
const [internalPagination, setInternalPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize,
});
// Wrap pagination setters to call callbacks
const handlePaginationChange = (updater: any) => {
setInternalPagination((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater;
// Call callbacks if pagination config exists
if (paginationConfig) {
if (next.pageIndex !== prev.pageIndex && paginationConfig.onPageChange) {
paginationConfig.onPageChange(next.pageIndex);
}
if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) {
paginationConfig.onPageSizeChange(next.pageSize);
}
}
return next;
});
};
// Sync pagination state to store so adapters can read pageIndex/pageSize
useEffect(() => {
if (paginationConfig?.enabled) {
setPaginationState(internalPagination);
}
}, [paginationConfig?.enabled, internalPagination, setPaginationState]);
// Resolve controlled vs uncontrolled
const sorting = controlledSorting ?? internalSorting;
const setSorting = onSortingChange ?? setInternalSorting;
const columnFilters = controlledFilters ?? internalFilters;
const setColumnFilters = onColumnFiltersChange ?? setInternalFilters;
const columnPinning = controlledPinning ?? internalPinning;
const setColumnPinning = onColumnPinningChange ?? setInternalPinning;
const rowSelectionState = controlledRowSelection ?? internalRowSelection;
const setRowSelection = onRowSelectionChange ?? setInternalRowSelection;
// ─── Selection config ───
const enableRowSelection = selection ? selection.mode !== 'none' : false;
const enableMultiRowSelection = selection?.mode === 'multi';
// ─── TanStack Table Instance ───
const table = useReactTable<T>({
columns,
data: transformedData as T[],
enableColumnResizing: true,
enableExpanding: true,
enableFilters: true,
enableGrouping: groupingConfig?.enabled ?? false,
enableMultiRowSelection,
enableMultiSort: true,
enablePinning: true,
enableRowSelection,
enableSorting: true,
getCoreRowModel: getCoreRowModel(),
...(advancedSearch?.enabled ? { globalFilterFn: advancedSearchGlobalFilterFn as any } : {}),
getExpandedRowModel: getExpandedRowModel(),
getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(),
getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined,
getRowId: (getRowId as any) ?? ((_, index) => String(index)),
// Tree support: configure getSubRows for TanStack Table
...(tree?.enabled
? {
filterFromLeafRows: true,
getSubRows: (row: any) => {
const childrenField = (tree.childrenField as string) || 'children';
if (childrenField !== 'subRows' && row[childrenField]) {
return row[childrenField];
}
return row.subRows;
},
}
: {}),
getSortedRowModel: manualSorting ? undefined : getSortedRowModel(),
manualFiltering: manualFiltering ?? false,
manualPagination: paginationConfig?.type === 'offset',
manualSorting: manualSorting ?? false,
onColumnFiltersChange: setColumnFilters as any,
onColumnOrderChange: setColumnOrder,
onColumnPinningChange: setColumnPinning as any,
onColumnVisibilityChange: setColumnVisibility,
onExpandedChange: setExpanded,
onGlobalFilterChange: setGlobalFilter,
onGroupingChange: setGrouping,
onPaginationChange: paginationConfig?.enabled ? handlePaginationChange : undefined,
onRowSelectionChange: setRowSelection as any,
onSortingChange: setSorting as any,
rowCount: dataCount,
state: {
columnFilters,
columnOrder,
columnPinning,
columnVisibility,
expanded,
globalFilter,
grouping,
rowSelection: rowSelectionState,
sorting,
...(paginationConfig?.enabled ? { pagination: internalPagination } : {}),
},
...(paginationConfig?.enabled && paginationConfig.type !== 'offset'
? { getPaginationRowModel: getPaginationRowModel() }
: {}),
columnResizeMode: 'onChange',
});
// ─── Scroll Container Ref ───
const scrollRef = useRef<HTMLDivElement>(null);
// ─── TanStack Virtual ───
const virtualizer = useGridVirtualizer({
overscan: effectiveOverscan,
rowHeight: effectiveRowHeight,
scrollRef,
table,
});
// ─── Sync table + virtualizer + scrollRef into store ───
useEffect(() => {
setTable(table);
}, [table, setTable]);
useEffect(() => {
setVirtualizer(virtualizer);
}, [virtualizer, setVirtualizer]);
useEffect(() => {
setScrollRef(scrollRef.current);
}, [setScrollRef]);
// ─── Tree Hooks ───
// Lazy tree expansion
useLazyTreeExpansion({
data: transformedData,
expanded,
setData,
setTreeChildrenCache,
setTreeLoadingNode,
table,
tree,
treeChildrenCache,
});
// Auto-expand on search
useAutoExpandOnSearch({
globalFilter,
table,
tree,
});
// ─── Keyboard Navigation ───
// Get the full store state for imperative access in keyboard handler
const storeState = useGriddyStore();
useKeyboardNavigation({
editingEnabled: !!onEditCommit,
scrollRef,
search,
selection,
storeState,
table,
tree,
virtualizer,
});
// ─── Set initial focus when data loads ───
const rowCount = table.getRowModel().rows.length;
useEffect(() => {
setTotalRows(rowCount);
if (rowCount > 0 && focusedRowIndex === null) {
setFocusedRow(0);
}
}, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow]);
// ─── Imperative Ref ───
useImperativeHandle(
tableRef,
() => ({
deselectAll: () => table.resetRowSelection(),
focusRow: (index: number) => {
setFocusedRow(index);
virtualizer.scrollToIndex(index, { align: 'auto' });
},
getTable: () => table,
getUIState: () =>
({
focusedColumnId: null,
focusedRowIndex,
isEditing: false,
isSearchOpen: false,
isSelecting: false,
totalRows: rowCount,
}) as any,
getVirtualizer: () => virtualizer,
scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }),
selectRow: (id: string) => {
const row = table.getRowModel().rows.find((r) => r.id === id);
row?.toggleSelected(true);
},
startEditing: (rowId: string, columnId?: string) => {
const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId);
if (rowIndex >= 0) {
setFocusedRow(rowIndex);
if (columnId) setFocusedColumn(columnId);
setEditing(true);
}
},
}),
[table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount]
);
// ─── Render ───
const containerStyle: React.CSSProperties = {
height: height ?? '100%',
overflow: 'auto',
position: 'relative',
};
const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null;
const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined;
return (
<div
aria-activedescendant={focusedRowId}
aria-label="Data grid"
aria-rowcount={(data ?? []).length}
className={[styles[CSS.root], className].filter(Boolean).join(' ')}
role="grid"
>
{search?.enabled && <SearchOverlay />}
{advancedSearch?.enabled && <AdvancedSearchPanel table={table} />}
{showToolbar && (
<GridToolbar
exportFilename={exportFilename}
filterPresets={filterPresets}
persistenceKey={persistenceKey}
table={table}
/>
)}
<div
className={styles[CSS.container]}
ref={scrollRef}
style={containerStyle}
tabIndex={enableKeyboard ? 0 : undefined}
>
<TableHeader />
{isLoading && (!data || data.length === 0) ? (
<GriddyLoadingSkeleton />
) : (
<>
<VirtualBody />
{isLoading && <GriddyLoadingOverlay />}
</>
)}
</div>
{paginationConfig?.enabled && (
<PaginationControl pageSizeOptions={paginationConfig.pageSizeOptions} table={table} />
)}
</div>
);
}
export const Griddy = forwardRef(_Griddy) as <T>(
props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>>
) => React.ReactElement;

View File

@@ -0,0 +1,175 @@
import type { Table } from '@tanstack/react-table';
import type {
ColumnFiltersState,
ColumnPinningState,
RowSelectionState,
SortingState,
} from '@tanstack/react-table';
import type { Virtualizer } from '@tanstack/react-virtual';
import { createSyncStore } from '@warkypublic/zustandsyncstore';
import type {
AdvancedSearchConfig,
DataAdapter,
GriddyColumn,
GriddyProps,
GriddyUIState,
GroupingConfig,
InfiniteScrollConfig,
PaginationConfig,
SearchConfig,
SelectionConfig,
TreeConfig,
} from './types';
// ─── Store State ─────────────────────────────────────────────────────────────
/**
* Full store state: UI state + synced props + internal references.
* Props from GriddyProps are synced automatically via createSyncStore's $sync.
* Fields from GriddyProps must be declared here so TypeScript can see them.
*/
export interface GriddyStoreState extends GriddyUIState {
_scrollRef: HTMLDivElement | null;
// ─── Internal refs (set imperatively) ───
_table: null | Table<any>;
_virtualizer: null | Virtualizer<HTMLDivElement, Element>;
advancedSearch?: AdvancedSearchConfig;
// ─── Adapter Actions ───
appendData: (data: any[]) => void;
className?: string;
columnFilters?: ColumnFiltersState;
columnPinning?: ColumnPinningState;
columns?: GriddyColumn<any>[];
data?: any[];
dataAdapter?: DataAdapter<any>;
dataCount?: number;
// ─── Error State ───
error: Error | null;
exportFilename?: string;
filterPresets?: boolean;
getRowId?: (row: any, index: number) => string;
grouping?: GroupingConfig;
height?: number | string;
infiniteScroll?: InfiniteScrollConfig;
isLoading?: boolean;
keyboardNavigation?: boolean;
manualFiltering?: boolean;
manualSorting?: boolean;
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
onColumnPinningChange?: (pinning: ColumnPinningState) => void;
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void;
onError?: (error: Error) => void;
onRowSelectionChange?: (selection: RowSelectionState) => void;
onSortingChange?: (sorting: SortingState) => void;
overscan?: number;
pagination?: PaginationConfig;
paginationState?: { pageIndex: number; pageSize: number };
persistenceKey?: string;
rowHeight?: number;
rowSelection?: RowSelectionState;
search?: SearchConfig;
selection?: SelectionConfig;
setData: (data: any[]) => void;
setDataCount: (count: number) => void;
setError: (error: Error | null) => void;
setInfiniteScroll: (config: InfiniteScrollConfig | undefined) => void;
setIsLoading: (loading: boolean) => void;
setPaginationState: (state: { pageIndex: number; pageSize: number }) => void;
setScrollRef: (el: HTMLDivElement | null) => void;
// ─── Internal ref setters ───
setTable: (table: Table<any>) => void;
setTreeChildrenCache: (nodeId: string, children: any[]) => void;
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
showToolbar?: boolean;
sorting?: SortingState;
// ─── Tree/Hierarchical Data ───
tree?: TreeConfig<any>;
treeChildrenCache: Map<string, any[]>;
treeLoadingNodes: Set<string>;
// ─── Synced from GriddyProps (written by $sync) ───
uniqueId?: string;
}
// ─── Create Store ────────────────────────────────────────────────────────────
export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
GriddyStoreState,
GriddyProps<any>
>((set, get) => ({
_scrollRef: null,
// ─── Internal Refs ───
_table: null,
_virtualizer: null,
appendData: (data) => set((state) => ({ data: [...(state.data ?? []), ...data] })),
error: null,
focusedColumnId: null,
// ─── Focus State ───
focusedRowIndex: null,
// ─── Mode State ───
isEditing: false,
isSearchOpen: false,
isSelecting: false,
moveFocus: (direction, amount) => {
const { focusedRowIndex, totalRows } = get();
const current = focusedRowIndex ?? 0;
const delta = direction === 'down' ? amount : -amount;
const next = Math.max(0, Math.min(current + delta, totalRows - 1));
set({ focusedRowIndex: next });
},
moveFocusToEnd: () => {
const { totalRows } = get();
set({ focusedRowIndex: Math.max(0, totalRows - 1) });
},
moveFocusToStart: () => set({ focusedRowIndex: 0 }),
setData: (data) => set({ data }),
setDataCount: (count) => set({ dataCount: count }),
setEditing: (editing) => set({ isEditing: editing }),
setError: (error) => set({ error }),
setFocusedColumn: (id) => set({ focusedColumnId: id }),
// ─── Actions ───
setFocusedRow: (index) => set({ focusedRowIndex: index }),
setInfiniteScroll: (config) => set({ infiniteScroll: config }),
setIsLoading: (loading) => set({ isLoading: loading }),
setPaginationState: (state) => set({ paginationState: state }),
setScrollRef: (el) => set({ _scrollRef: el }),
setSearchOpen: (open) => set({ isSearchOpen: open }),
setSelecting: (selecting) => set({ isSelecting: selecting }),
// ─── Internal Ref Setters ───
setTable: (table) => set({ _table: table }),
setTotalRows: (count) => set({ totalRows: count }),
setTreeChildrenCache: (nodeId, children) =>
set((state) => {
const newMap = new Map(state.treeChildrenCache);
newMap.set(nodeId, children);
return { treeChildrenCache: newMap };
}),
setTreeLoadingNode: (nodeId, loading) =>
set((state) => {
const newSet = new Set(state.treeLoadingNodes);
if (loading) {
newSet.add(nodeId);
} else {
newSet.delete(nodeId);
}
return { treeLoadingNodes: newSet };
}),
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
// ─── Row Count ───
totalRows: 0,
treeChildrenCache: new Map(),
// ─── Tree State ───
treeLoadingNodes: new Set(),
}));

View File

@@ -0,0 +1,132 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { GriddyColumn, SelectionConfig } from './types';
import { createOperatorFilter } from '../features/filtering';
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants';
/**
* Retrieves the original GriddyColumn from a TanStack column's meta.
*/
export function getGriddyColumn<T>(column: {
columnDef: ColumnDef<T>;
}): GriddyColumn<T> | undefined {
return (column.columnDef.meta as { griddy?: GriddyColumn<T> })?.griddy;
}
/**
* Maps Griddy's user-facing GriddyColumn<T> definitions to TanStack Table ColumnDef<T>[].
* Supports header grouping and optionally prepends a selection checkbox column.
*/
export function mapColumns<T>(
columns: GriddyColumn<T>[],
selection?: SelectionConfig
): ColumnDef<T>[] {
// Group columns by headerGroup
const grouped = new Map<string, GriddyColumn<T>[]>();
const ungrouped: GriddyColumn<T>[] = [];
columns.forEach((col) => {
if (col.headerGroup) {
const existing = grouped.get(col.headerGroup) || [];
existing.push(col);
grouped.set(col.headerGroup, existing);
} else {
ungrouped.push(col);
}
});
// Build column definitions
const mapped: ColumnDef<T>[] = [];
// Add ungrouped columns first
ungrouped.forEach((col) => {
mapped.push(mapSingleColumn(col));
});
// Add grouped columns
grouped.forEach((groupColumns, groupName) => {
const groupDef: ColumnDef<T> = {
columns: groupColumns.map((col) => mapSingleColumn(col)),
header: groupName,
id: `group-${groupName}`,
};
mapped.push(groupDef);
});
// Prepend checkbox column if selection is enabled
if (selection && selection.mode !== 'none' && selection.showCheckbox !== false) {
const checkboxCol: ColumnDef<T> = {
cell: 'select-row', // Rendered by TableCell with actual checkbox
enableColumnFilter: false,
enableHiding: false,
enableResizing: false,
enableSorting: false,
header:
selection.mode === 'multi'
? 'select-all' // Rendered by TableHeader with actual checkbox
: '',
id: SELECTION_COLUMN_ID,
size: SELECTION_COLUMN_SIZE,
};
mapped.unshift(checkboxCol);
}
return mapped;
}
/**
* Converts a single GriddyColumn to a TanStack ColumnDef
*/
function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> {
const isStringAccessor = typeof col.accessor !== 'function';
const def: ColumnDef<T> = {
id: col.id,
// Use accessorKey for string keys (enables TanStack auto-detection of sort/filter),
// accessorFn for function accessors
...(isStringAccessor
? { accessorKey: col.accessor as string }
: { accessorFn: col.accessor as (row: T) => unknown }),
aggregationFn: col.aggregationFn,
enableColumnFilter: col.filterable ?? false,
enableGrouping: col.groupable ?? false,
enableHiding: true,
enablePinning: true,
enableResizing: true,
enableSorting: col.sortable ?? true,
header: () => col.header,
maxSize: col.maxWidth ?? DEFAULTS.maxColumnWidth,
meta: { griddy: col },
minSize: col.minWidth ?? DEFAULTS.minColumnWidth,
size: col.width,
};
// For function accessors, TanStack can't auto-detect the sort type, so provide a default
if (col.sortFn) {
def.sortingFn = col.sortFn;
} else if (!isStringAccessor && col.sortable !== false) {
// Use alphanumeric sorting for function accessors
def.sortingFn = 'alphanumeric';
}
if (col.filterFn) {
def.filterFn = col.filterFn;
} else if (col.filterable) {
def.filterFn = createOperatorFilter();
}
if (col.renderer) {
const renderer = col.renderer;
def.cell = (info) =>
renderer({
column: col,
columnIndex: info.cell.column.getIndex(),
row: info.row.original,
rowIndex: info.row.index,
value: info.getValue(),
});
}
return def;
}

View File

@@ -0,0 +1,47 @@
// ─── CSS Class Names ─────────────────────────────────────────────────────────
export const CSS = {
cell: 'griddy-cell',
cellEditing: 'griddy-cell--editing',
checkbox: 'griddy-checkbox',
container: 'griddy-container',
filterButton: 'griddy-filter-button',
filterButtonActive: 'griddy-filter-button--active',
headerCell: 'griddy-header-cell',
headerCellContent: 'griddy-header-cell-content',
headerCellSortable: 'griddy-header-cell--sortable',
headerCellSorted: 'griddy-header-cell--sorted',
headerRow: 'griddy-header-row',
resizeHandle: 'griddy-resize-handle',
root: 'griddy',
row: 'griddy-row',
rowEven: 'griddy-row--even',
rowFocused: 'griddy-row--focused',
rowOdd: 'griddy-row--odd',
rowSelected: 'griddy-row--selected',
searchInput: 'griddy-search-input',
searchOverlay: 'griddy-search-overlay',
sortIndicator: 'griddy-sort-indicator',
table: 'griddy-table',
tbody: 'griddy-tbody',
thead: 'griddy-thead',
} as const
// ─── Defaults ────────────────────────────────────────────────────────────────
export const DEFAULTS = {
filterDebounceMs: 300,
headerHeight: 36,
maxColumnWidth: 800,
minColumnWidth: 50,
overscan: 10,
pageSize: 50,
pageSizeOptions: [25, 50, 100] as number[],
rowHeight: 36,
searchDebounceMs: 300,
} as const
// ─── Selection Column ────────────────────────────────────────────────────────
export const SELECTION_COLUMN_ID = '_selection'
export const SELECTION_COLUMN_SIZE = 40

367
src/Griddy/core/types.ts Normal file
View File

@@ -0,0 +1,367 @@
import type {
ColumnDef,
ColumnFiltersState,
ColumnOrderState,
ColumnPinningState,
ExpandedState,
FilterFn,
GroupingState,
PaginationState,
RowSelectionState,
SortingFn,
SortingState,
Table,
VisibilityState,
} from '@tanstack/react-table';
import type { Virtualizer } from '@tanstack/react-virtual';
import type { ReactNode } from 'react';
import type { EditorConfig } from '../editors';
import type { FilterConfig } from '../features/filtering';
// ─── Column Definition ───────────────────────────────────────────────────────
export interface AdvancedSearchConfig {
enabled: boolean;
}
// ─── Cell Rendering ──────────────────────────────────────────────────────────
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode;
export interface DataAdapter<T> {
delete?: (row: T) => Promise<void>;
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>;
save?: (row: T) => Promise<void>;
}
// ─── Editors ─────────────────────────────────────────────────────────────────
export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode;
export interface EditorProps<T> {
column: GriddyColumn<T>;
onCancel: () => void;
onCommit: (newValue: unknown) => void;
onMoveNext: () => void;
onMovePrev: () => void;
row: T;
rowIndex: number;
value: unknown;
}
// ─── Selection ───────────────────────────────────────────────────────────────
export interface FetchConfig {
cursor?: string;
filters?: ColumnFiltersState;
globalFilter?: string;
page?: number;
pageSize?: number;
sorting?: SortingState;
}
// ─── Search ──────────────────────────────────────────────────────────────────
export interface GriddyColumn<T> {
accessor: ((row: T) => unknown) | keyof T;
aggregationFn?: 'count' | 'max' | 'mean' | 'median' | 'min' | 'sum' | 'unique' | 'uniqueCount';
editable?: ((row: T) => boolean) | boolean;
editor?: EditorComponent<T>;
editorConfig?: EditorConfig;
filterable?: boolean;
filterConfig?: FilterConfig;
filterFn?: FilterFn<T>;
groupable?: boolean;
header: ReactNode | string;
headerGroup?: string;
hidden?: boolean;
id: string;
maxWidth?: number;
minWidth?: number;
pinned?: 'left' | 'right';
renderer?: CellRenderer<T>;
/** Metadata passed to custom renderers (ProgressBar, Badge, Image, Sparkline) */
rendererMeta?: Record<string, unknown>;
searchable?: boolean;
sortable?: boolean;
sortFn?: SortingFn<T>;
width?: number;
}
// ─── Pagination ──────────────────────────────────────────────────────────────
export interface GriddyDataSource<T> {
data: T[];
error?: Error;
isLoading?: boolean;
pageInfo?: { cursor?: string; hasNextPage: boolean };
total?: number;
}
export interface GriddyProps<T> {
// ─── Advanced Search ───
advancedSearch?: AdvancedSearchConfig;
// ─── Children (adapters, etc.) ───
children?: ReactNode;
// ─── Styling ───
className?: string;
// ─── Filtering ───
/** Controlled column filters state */
columnFilters?: ColumnFiltersState;
/** Controlled column pinning state */
columnPinning?: ColumnPinningState;
/** Column definitions */
columns: GriddyColumn<T>[];
/** Data array */
data: T[];
// ─── Data Adapter ───
dataAdapter?: DataAdapter<T>;
/** Total row count (for server-side pagination/filtering). If provided, enables manual mode. */
dataCount?: number;
/** Export filename. Default: 'export.csv' */
exportFilename?: string;
// ─── Filter Presets ───
/** Enable filter presets save/load in toolbar. Default: false */
filterPresets?: boolean;
/** Stable row identity function */
getRowId?: (row: T, index: number) => string;
// ─── Grouping ───
grouping?: GroupingConfig;
/** Container height */
height?: number | string;
// ─── Infinite Scroll ───
/** Infinite scroll configuration */
infiniteScroll?: InfiniteScrollConfig;
// ─── Loading ───
/** Show loading skeleton/overlay. Default: false */
isLoading?: boolean;
// ─── Keyboard ───
/** Enable keyboard navigation. Default: true */
keyboardNavigation?: boolean;
/** Manual filtering mode - filtering handled externally (server-side). Default: false */
manualFiltering?: boolean;
/** Manual sorting mode - sorting handled externally (server-side). Default: false */
manualSorting?: boolean;
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
onColumnPinningChange?: (pinning: ColumnPinningState) => void;
// ─── Editing ───
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void;
// ─── Error Handling ───
/** Callback when a render error is caught by the error boundary */
onError?: (error: Error) => void;
/** Callback before the error boundary retries rendering */
onRetry?: () => void;
/** Selection change callback */
onRowSelectionChange?: (selection: RowSelectionState) => void;
onSortingChange?: (sorting: SortingState) => void;
/** Overscan row count. Default: 10 */
overscan?: number;
// ─── Pagination ───
pagination?: PaginationConfig;
// ─── Persistence ───
/** localStorage key prefix for persisting column layout */
persistenceKey?: string;
// ─── Virtualization ───
/** Row height in pixels. Default: 36 */
rowHeight?: number;
/** Controlled row selection state */
rowSelection?: RowSelectionState;
// ─── Search ───
search?: SearchConfig;
// ─── Selection ───
/** Selection configuration */
selection?: SelectionConfig;
// ─── Toolbar ───
/** Show toolbar with export and column visibility controls. Default: false */
showToolbar?: boolean;
// ─── Sorting ───
/** Controlled sorting state */
sorting?: SortingState;
// ─── Tree/Hierarchical Data ───
/** Tree/hierarchical data configuration */
tree?: TreeConfig<T>;
/** Unique identifier for persistence */
uniqueId?: string;
}
// ─── Data Adapter ────────────────────────────────────────────────────────────
export interface GriddyRef<T = unknown> {
deselectAll: () => void;
focusRow: (index: number) => void;
getTable: () => Table<T>;
getUIState: () => GriddyUIState;
getVirtualizer: () => Virtualizer<HTMLDivElement, Element>;
scrollToRow: (index: number) => void;
selectRow: (id: string) => void;
startEditing: (rowId: string, columnId?: string) => void;
}
export interface GriddyUIState {
focusedColumnId: null | string;
// Focus
focusedRowIndex: null | number;
// Modes
isEditing: boolean;
isSearchOpen: boolean;
isSelecting: boolean;
moveFocus: (direction: 'down' | 'up', amount: number) => void;
moveFocusToEnd: () => void;
moveFocusToStart: () => void;
setEditing: (editing: boolean) => void;
setFocusedColumn: (id: null | string) => void;
// Actions
setFocusedRow: (index: null | number) => void;
setSearchOpen: (open: boolean) => void;
setSelecting: (selecting: boolean) => void;
setTotalRows: (count: number) => void;
// Row count (synced from table)
totalRows: number;
}
export interface GroupingConfig {
columns?: string[];
enabled: boolean;
}
// ─── Grouping ────────────────────────────────────────────────────────────────
export interface InfiniteScrollConfig {
/** Enable infinite scroll */
enabled: boolean;
/** Whether there is more data to load */
hasMore?: boolean;
/** Whether data is currently loading */
isLoading?: boolean;
/** Callback to load more data. Should update the data array. */
onLoadMore?: () => Promise<void> | void;
/** Threshold in rows from the end to trigger loading. Default: 10 */
threshold?: number;
}
export interface PaginationConfig {
enabled: boolean;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
pageSize: number;
pageSizeOptions?: number[];
type: 'cursor' | 'offset';
}
// ─── Main Props ──────────────────────────────────────────────────────────────
export interface RendererProps<T> {
column: GriddyColumn<T>;
columnIndex: number;
isEditing?: boolean;
row: T;
rowIndex: number;
searchQuery?: string;
value: unknown;
}
// ─── UI State (Zustand Store) ────────────────────────────────────────────────
export interface SearchConfig {
caseSensitive?: boolean;
debounceMs?: number;
enabled: boolean;
fuzzy?: boolean;
highlightMatches?: boolean;
placeholder?: string;
}
// ─── Ref API ─────────────────────────────────────────────────────────────────
export interface SelectionConfig {
/** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */
mode: 'multi' | 'none' | 'single';
/** Maintain selection across pagination/sorting. Default: true */
preserveSelection?: boolean;
/** Allow clicking row body to toggle selection. Default: true */
selectOnClick?: boolean;
/** Show checkbox column (auto-added as first column). Default: true when mode !== 'none' */
showCheckbox?: boolean;
}
// ─── Tree/Hierarchical Data ──────────────────────────────────────────────────
export interface TreeConfig<T> {
// ─── UI Configuration ───
/** Auto-expand parent nodes when search matches children. Default: true */
autoExpandOnSearch?: boolean;
// ─── Nested Mode ───
/** Field name for children array in nested mode. Default: 'children' */
childrenField?: keyof T | string;
// ─── Expansion State ───
/** Default expanded state (record or array of IDs) */
defaultExpanded?: Record<string, boolean> | string[];
/** Enable tree/hierarchical data mode */
enabled: boolean;
/** Controlled expanded state */
expanded?: Record<string, boolean>;
// ─── Lazy Mode ───
/** Async function to fetch children for a parent node */
getChildren?: (parent: T) => Promise<T[]> | T[];
/** Function to determine if a node has children (for lazy mode) */
hasChildren?: (row: T) => boolean;
/** Custom icons for tree states */
icons?: {
collapsed?: ReactNode;
expanded?: ReactNode;
leaf?: ReactNode;
};
/** Indentation size per depth level in pixels. Default: 20 */
indentSize?: number;
/** Maximum tree depth to render. Default: Infinity */
maxDepth?: number;
/** Tree data mode. Default: 'nested' */
mode?: 'flat' | 'lazy' | 'nested';
/** Callback when expanded state changes */
onExpandedChange?: (expanded: Record<string, boolean>) => void;
// ─── Flat Mode ───
/** Field name for parent ID in flat mode. Default: 'parentId' */
parentIdField?: keyof T | string;
/** Show expand/collapse icon. Default: true */
showExpandIcon?: boolean;
}
// ─── Re-exports for convenience ──────────────────────────────────────────────
export type {
ColumnDef,
ColumnFiltersState,
ColumnOrderState,
ColumnPinningState,
ExpandedState,
GroupingState,
PaginationState,
RowSelectionState,
SortingState,
Table,
VisibilityState,
};

View File

@@ -0,0 +1,43 @@
import { Checkbox } from '@mantine/core'
import { useEffect, useState } from 'react'
import type { BaseEditorProps } from './types'
export function CheckboxEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps<boolean>) {
const [checked, setChecked] = useState(Boolean(value))
useEffect(() => {
setChecked(Boolean(value))
}, [value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
onCommit(checked)
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
} else if (e.key === 'Tab') {
e.preventDefault()
onCommit(checked)
if (e.shiftKey) {
onMovePrev?.()
} else {
onMoveNext?.()
}
} else if (e.key === ' ') {
e.preventDefault()
setChecked(!checked)
}
}
return (
<Checkbox
autoFocus={autoFocus}
checked={checked}
onChange={(e) => setChecked(e.currentTarget.checked)}
onKeyDown={handleKeyDown}
size="xs"
/>
)
}

View File

@@ -0,0 +1,46 @@
import { DatePickerInput } from '@mantine/dates'
import { useEffect, useState } from 'react'
import type { BaseEditorProps } from './types'
export function DateEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps<Date | string>) {
const [dateValue, setDateValue] = useState<Date | null>(() =>
value ? new Date(value) : null
)
useEffect(() => {
setDateValue(value ? new Date(value) : null)
}, [value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
onCommit(dateValue ?? '')
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
} else if (e.key === 'Tab') {
e.preventDefault()
onCommit(dateValue ?? '')
if (e.shiftKey) {
onMovePrev?.()
} else {
onMoveNext?.()
}
}
}
return (
<DatePickerInput
autoFocus={autoFocus}
clearable
onChange={(date) => {
const dateVal = date ? (typeof date === 'string' ? new Date(date) : date) : null
setDateValue(dateVal)
}}
onKeyDown={handleKeyDown}
size="xs"
value={dateValue}
/>
)
}

View File

@@ -0,0 +1,49 @@
import { NumberInput } from '@mantine/core'
import { useEffect, useState } from 'react'
import type { BaseEditorProps } from './types'
interface NumericEditorProps extends BaseEditorProps<number> {
max?: number
min?: number
step?: number
}
export function NumericEditor({ autoFocus = true, max, min, onCancel, onCommit, onMoveNext, onMovePrev, step = 1, value }: NumericEditorProps) {
const [inputValue, setInputValue] = useState<number | string>(value ?? '')
useEffect(() => {
setInputValue(value ?? '')
}, [value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
onCommit(typeof inputValue === 'number' ? inputValue : Number(inputValue))
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
} else if (e.key === 'Tab') {
e.preventDefault()
onCommit(typeof inputValue === 'number' ? inputValue : Number(inputValue))
if (e.shiftKey) {
onMovePrev?.()
} else {
onMoveNext?.()
}
}
}
return (
<NumberInput
autoFocus={autoFocus}
max={max}
min={min}
onChange={(val) => setInputValue(val ?? '')}
onKeyDown={handleKeyDown}
size="xs"
step={step}
value={inputValue}
/>
)
}

View File

@@ -0,0 +1,59 @@
import { Select } from '@mantine/core';
import { useEffect, useState } from 'react';
import type { BaseEditorProps, SelectOption } from './types';
interface SelectEditorProps extends BaseEditorProps<any> {
options: SelectOption[];
}
export function SelectEditor({
autoFocus = true,
onCancel,
onCommit,
onMoveNext,
onMovePrev,
options,
value,
}: SelectEditorProps) {
const [selectedValue, setSelectedValue] = useState<null | string>(
value != null ? String(value) : null
);
useEffect(() => {
setSelectedValue(value != null ? String(value) : null);
}, [value]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
// Find the actual value from options
const option = options.find((opt) => String(opt.value) === selectedValue);
onCommit(option?.value ?? selectedValue);
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
} else if (e.key === 'Tab') {
e.preventDefault();
const option = options.find((opt) => String(opt.value) === selectedValue);
onCommit(option?.value ?? selectedValue);
if (e.shiftKey) {
onMovePrev?.();
} else {
onMoveNext?.();
}
}
};
return (
<Select
autoFocus={autoFocus}
data={options.map((opt) => ({ label: opt.label, value: String(opt.value) }))}
onChange={(val) => setSelectedValue(val)}
onKeyDown={handleKeyDown}
searchable
size="xs"
value={selectedValue}
/>
);
}

View File

@@ -0,0 +1,40 @@
import { TextInput } from '@mantine/core'
import { useEffect, useState } from 'react'
import type { BaseEditorProps } from './types'
export function TextEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps<string>) {
const [inputValue, setInputValue] = useState(value ?? '')
useEffect(() => {
setInputValue(value ?? '')
}, [value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
onCommit(inputValue)
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
} else if (e.key === 'Tab') {
e.preventDefault()
onCommit(inputValue)
if (e.shiftKey) {
onMovePrev?.()
} else {
onMoveNext?.()
}
}
}
return (
<TextInput
autoFocus={autoFocus}
onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={handleKeyDown}
size="xs"
value={inputValue}
/>
)
}

View File

@@ -0,0 +1,6 @@
export { CheckboxEditor } from './CheckboxEditor'
export { DateEditor } from './DateEditor'
export { NumericEditor } from './NumericEditor'
export { SelectEditor } from './SelectEditor'
export { TextEditor } from './TextEditor'
export type { BaseEditorProps, EditorComponent, EditorConfig, EditorType, SelectOption, ValidationResult, ValidationRule } from './types'

View File

@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
// ─── Editor Props ────────────────────────────────────────────────────────────
export interface BaseEditorProps<T = any> {
autoFocus?: boolean;
onCancel: () => void;
onCommit: (value: T) => void;
onMoveNext?: () => void;
onMovePrev?: () => void;
value: T;
}
// ─── Validation ──────────────────────────────────────────────────────────────
export type EditorComponent<T = any> = (props: BaseEditorProps<T>) => ReactNode;
export interface EditorConfig {
max?: number;
min?: number;
options?: SelectOption[];
placeholder?: string;
step?: number;
type?: EditorType;
validation?: ValidationRule[];
}
// ─── Editor Registry ─────────────────────────────────────────────────────────
export type EditorType = 'checkbox' | 'date' | 'number' | 'select' | 'text';
export interface SelectOption {
label: string;
value: any;
}
export interface ValidationResult {
errors: string[];
isValid: boolean;
}
export interface ValidationRule<T = any> {
message: string;
validate: (value: T) => boolean;
}

View File

@@ -0,0 +1,146 @@
import type { Table } from '@tanstack/react-table';
import { Button, Group, SegmentedControl, Stack, Text } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types';
import { useGriddyStore } from '../../core/GriddyStore';
import styles from '../../styles/griddy.module.css';
import { advancedFilter } from './advancedFilterFn';
import { SearchConditionRow } from './SearchConditionRow';
let nextId = 1;
interface AdvancedSearchPanelProps {
table: Table<any>;
}
// Custom global filter function that handles advanced search
export function advancedSearchGlobalFilterFn(
row: any,
_columnId: string,
filterValue: any
): boolean {
if (filterValue?._advancedSearch) {
return advancedFilter(row, filterValue._advancedSearch);
}
// Fallback to default string search
if (typeof filterValue === 'string') {
const search = filterValue.toLowerCase();
return row.getAllCells().some((cell: any) => {
const val = cell.getValue();
return String(val ?? '')
.toLowerCase()
.includes(search);
});
}
return true;
}
export function AdvancedSearchPanel({ table }: AdvancedSearchPanelProps) {
const userColumns = useGriddyStore((s) => s.columns) ?? [];
const [searchState, setSearchState] = useState<AdvancedSearchState>({
booleanOperator: 'AND',
conditions: [createCondition()],
});
const columnOptions = useMemo(
() =>
userColumns
.filter((c) => c.searchable !== false)
.map((c) => ({ label: String(c.header), value: c.id })),
[userColumns]
);
const handleConditionChange = useCallback((index: number, condition: SearchCondition) => {
setSearchState((prev) => {
const conditions = [...prev.conditions];
conditions[index] = condition;
return { ...prev, conditions };
});
}, []);
const handleRemove = useCallback((index: number) => {
setSearchState((prev) => ({
...prev,
conditions: prev.conditions.filter((_, i) => i !== index),
}));
}, []);
const handleAdd = useCallback(() => {
setSearchState((prev) => ({
...prev,
conditions: [...prev.conditions, createCondition()],
}));
}, []);
const handleApply = useCallback(() => {
const activeConditions = searchState.conditions.filter((c) => c.columnId && c.value);
if (activeConditions.length === 0) {
table.setGlobalFilter(undefined);
return;
}
// Use globalFilter with a custom function key
table.setGlobalFilter({ _advancedSearch: searchState });
}, [searchState, table]);
const handleClear = useCallback(() => {
setSearchState({ booleanOperator: 'AND', conditions: [createCondition()] });
table.setGlobalFilter(undefined);
}, [table]);
return (
<div className={styles['griddy-advanced-search']}>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
Advanced Search
</Text>
<SegmentedControl
data={['AND', 'OR', 'NOT']}
onChange={(val) =>
setSearchState((prev) => ({ ...prev, booleanOperator: val as BooleanOperator }))
}
size="xs"
value={searchState.booleanOperator}
/>
</Group>
{searchState.conditions.map((condition, index) => (
<SearchConditionRow
columns={columnOptions}
condition={condition}
key={condition.id}
onChange={(c) => handleConditionChange(index, c)}
onRemove={() => handleRemove(index)}
/>
))}
<Group justify="space-between">
<Button
leftSection={<IconPlus size={14} />}
onClick={handleAdd}
size="xs"
variant="subtle"
>
Add condition
</Button>
<Group gap="xs">
<Button onClick={handleClear} size="xs" variant="subtle">
Clear
</Button>
<Button onClick={handleApply} size="xs">
Search
</Button>
</Group>
</Group>
</Stack>
</div>
);
}
function createCondition(): SearchCondition {
return { columnId: '', id: String(nextId++), operator: 'contains', value: '' };
}

View File

@@ -0,0 +1,58 @@
import { ActionIcon, Group, Select, TextInput } from '@mantine/core'
import { IconTrash } from '@tabler/icons-react'
import type { SearchCondition } from './types'
interface SearchConditionRowProps {
columns: { label: string; value: string }[]
condition: SearchCondition
onChange: (condition: SearchCondition) => void
onRemove: () => void
}
const OPERATORS = [
{ label: 'Contains', value: 'contains' },
{ label: 'Equals', value: 'equals' },
{ label: 'Starts with', value: 'startsWith' },
{ label: 'Ends with', value: 'endsWith' },
{ label: 'Not contains', value: 'notContains' },
{ label: 'Greater than', value: 'greaterThan' },
{ label: 'Less than', value: 'lessThan' },
]
export function SearchConditionRow({ columns, condition, onChange, onRemove }: SearchConditionRowProps) {
return (
<Group gap="xs" wrap="nowrap">
<Select
data={columns}
onChange={(val) => onChange({ ...condition, columnId: val ?? '' })}
placeholder="Column"
size="xs"
value={condition.columnId || null}
w={140}
/>
<Select
data={OPERATORS}
onChange={(val) => onChange({ ...condition, operator: (val as SearchCondition['operator']) ?? 'contains' })}
size="xs"
value={condition.operator}
w={130}
/>
<TextInput
onChange={(e) => onChange({ ...condition, value: e.currentTarget.value })}
placeholder="Value"
size="xs"
style={{ flex: 1 }}
value={condition.value}
/>
<ActionIcon
color="red"
onClick={onRemove}
size="sm"
variant="subtle"
>
<IconTrash size={14} />
</ActionIcon>
</Group>
)
}

View File

@@ -0,0 +1,45 @@
import type { Row } from '@tanstack/react-table';
import type { AdvancedSearchState, SearchCondition } from './types';
export function advancedFilter<T>(row: Row<T>, searchState: AdvancedSearchState): boolean {
const { booleanOperator, conditions } = searchState;
const active = conditions.filter((c) => c.columnId && c.value);
if (active.length === 0) return true;
switch (booleanOperator) {
case 'AND':
return active.every((c) => matchCondition(row, c));
case 'NOT':
return !active.some((c) => matchCondition(row, c));
case 'OR':
return active.some((c) => matchCondition(row, c));
default:
return true;
}
}
function matchCondition<T>(row: Row<T>, condition: SearchCondition): boolean {
const cellValue = String(row.getValue(condition.columnId) ?? '').toLowerCase();
const searchValue = condition.value.toLowerCase();
switch (condition.operator) {
case 'contains':
return cellValue.includes(searchValue);
case 'endsWith':
return cellValue.endsWith(searchValue);
case 'equals':
return cellValue === searchValue;
case 'greaterThan':
return Number(row.getValue(condition.columnId)) > Number(condition.value);
case 'lessThan':
return Number(row.getValue(condition.columnId)) < Number(condition.value);
case 'notContains':
return !cellValue.includes(searchValue);
case 'startsWith':
return cellValue.startsWith(searchValue);
default:
return true;
}
}

View File

@@ -0,0 +1,2 @@
export { advancedSearchGlobalFilterFn, AdvancedSearchPanel } from './AdvancedSearchPanel';
export type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types';

View File

@@ -0,0 +1,20 @@
export interface AdvancedSearchState {
booleanOperator: BooleanOperator;
conditions: SearchCondition[];
}
export type BooleanOperator = 'AND' | 'NOT' | 'OR';
export interface SearchCondition {
columnId: string;
id: string;
operator:
| 'contains'
| 'endsWith'
| 'equals'
| 'greaterThan'
| 'lessThan'
| 'notContains'
| 'startsWith';
value: string;
}

View File

@@ -0,0 +1,48 @@
import type { Table } from '@tanstack/react-table'
import { ActionIcon, Checkbox, Menu, Stack } from '@mantine/core'
import { IconColumns } from '@tabler/icons-react'
interface ColumnVisibilityMenuProps<T> {
table: Table<T>
}
export function ColumnVisibilityMenu<T>({ table }: ColumnVisibilityMenuProps<T>) {
const columns = table.getAllColumns().filter(col =>
col.id !== '_selection' && col.getCanHide()
)
if (columns.length === 0) {
return null
}
return (
<Menu position="bottom-end" shadow="md" width={200}>
<Menu.Target>
<ActionIcon aria-label="Toggle columns" size="sm" variant="subtle">
<IconColumns size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Toggle Columns</Menu.Label>
<Stack gap="xs" p="xs">
{columns.map(column => {
const header = column.columnDef.header
const label = typeof header === 'string' ? header : column.id
return (
<Checkbox
checked={column.getIsVisible()}
key={column.id}
label={label}
onChange={column.getToggleVisibilityHandler()}
size="xs"
/>
)
})}
</Stack>
</Menu.Dropdown>
</Menu>
)
}

View File

@@ -0,0 +1 @@
export { ColumnVisibilityMenu } from './ColumnVisibilityMenu'

View File

@@ -0,0 +1,58 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import styles from '../../styles/griddy.module.css'
interface ErrorBoundaryProps {
children: ReactNode
onError?: (error: Error) => void
onRetry?: () => void
}
interface ErrorBoundaryState {
error: Error | null
}
export class GriddyErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error)
console.error('[Griddy] Render error:', error, info)
}
handleRetry = () => {
this.props.onRetry?.()
// Defer clearing the error state to allow the parent's onRetry state update
// (e.g., resetting shouldError) to flush before we re-render children
setTimeout(() => this.setState({ error: null }), 0)
}
render() {
if (this.state.error) {
return (
<div className={styles['griddy-error']}>
<div className={styles['griddy-error-icon']}>!</div>
<div className={styles['griddy-error-message']}>
Something went wrong rendering the grid.
</div>
<div className={styles['griddy-error-detail']}>
{this.state.error.message}
</div>
<button
className={styles['griddy-error-retry']}
onClick={this.handleRetry}
type="button"
>
Retry
</button>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1 @@
export { GriddyErrorBoundary } from './GriddyErrorBoundary'

View File

@@ -0,0 +1,99 @@
import type { Table } from '@tanstack/react-table'
/**
* Export table data to CSV file
*/
export function exportToCsv<T>(table: Table<T>, filename: string = 'export.csv') {
const rows = table.getFilteredRowModel().rows
const columns = table.getVisibleLeafColumns().filter(col => col.id !== '_selection')
// Build CSV header
const headers = columns.map(col => {
const header = col.columnDef.header
return typeof header === 'string' ? header : col.id
})
// Build CSV rows
const csvRows = rows.map(row => {
return columns.map(col => {
const cell = row.getAllCells().find(c => c.column.id === col.id)
if (!cell) return ''
const value = cell.getValue()
// Handle different value types
if (value == null) return ''
if (typeof value === 'object' && value instanceof Date) {
return value.toISOString()
}
const stringValue = String(value)
// Escape quotes and wrap in quotes if needed
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
})
})
// Combine header and rows
const csv = [
headers.join(','),
...csvRows.map(row => row.join(','))
].join('\n')
// Create blob and download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* Get CSV string without downloading
*/
export function getTableCsv<T>(table: Table<T>): string {
const rows = table.getFilteredRowModel().rows
const columns = table.getVisibleLeafColumns().filter(col => col.id !== '_selection')
const headers = columns.map(col => {
const header = col.columnDef.header
return typeof header === 'string' ? header : col.id
})
const csvRows = rows.map(row => {
return columns.map(col => {
const cell = row.getAllCells().find(c => c.column.id === col.id)
if (!cell) return ''
const value = cell.getValue()
if (value == null) return ''
if (typeof value === 'object' && value instanceof Date) {
return value.toISOString()
}
const stringValue = String(value)
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
})
})
return [
headers.join(','),
...csvRows.map(row => row.join(','))
].join('\n')
}

View File

@@ -0,0 +1 @@
export { exportToCsv, getTableCsv } from './exportCsv'

View File

@@ -0,0 +1,96 @@
import type { Table } from '@tanstack/react-table'
import { ActionIcon, Button, Group, Menu, Text, TextInput } from '@mantine/core'
import { IconBookmark, IconTrash } from '@tabler/icons-react'
import { useState } from 'react'
import { useFilterPresets } from './useFilterPresets'
interface FilterPresetsMenuProps {
persistenceKey?: string
table: Table<any>
}
export function FilterPresetsMenu({ persistenceKey, table }: FilterPresetsMenuProps) {
const { addPreset, deletePreset, presets } = useFilterPresets(persistenceKey)
const [newName, setNewName] = useState('')
const [opened, setOpened] = useState(false)
const handleSave = () => {
if (!newName.trim()) return
addPreset({
columnFilters: table.getState().columnFilters,
globalFilter: table.getState().globalFilter,
name: newName.trim(),
})
setNewName('')
}
const handleLoad = (preset: typeof presets[0]) => {
table.setColumnFilters(preset.columnFilters)
if (preset.globalFilter !== undefined) {
table.setGlobalFilter(preset.globalFilter)
}
setOpened(false)
}
return (
<Menu onChange={setOpened} opened={opened} position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
aria-label="Filter presets"
size="sm"
variant="subtle"
>
<IconBookmark size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Saved Presets</Menu.Label>
{presets.length === 0 && (
<Menu.Item disabled>
<Text c="dimmed" size="xs">No presets saved</Text>
</Menu.Item>
)}
{presets.map((preset) => (
<Menu.Item
key={preset.id}
onClick={() => handleLoad(preset)}
rightSection={
<ActionIcon
color="red"
onClick={(e) => {
e.stopPropagation()
deletePreset(preset.id)
}}
size="xs"
variant="subtle"
>
<IconTrash size={12} />
</ActionIcon>
}
>
{preset.name}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Label>Save Current Filters</Menu.Label>
<div style={{ padding: '4px 12px 8px' }}>
<Group gap="xs">
<TextInput
onChange={(e) => setNewName(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
placeholder="Preset name"
size="xs"
style={{ flex: 1 }}
value={newName}
/>
<Button onClick={handleSave} size="xs">
Save
</Button>
</Group>
</div>
</Menu.Dropdown>
</Menu>
)
}

View File

@@ -0,0 +1,3 @@
export { FilterPresetsMenu } from './FilterPresetsMenu'
export type { FilterPreset } from './types'
export { useFilterPresets } from './useFilterPresets'

View File

@@ -0,0 +1,8 @@
import type { ColumnFiltersState } from '@tanstack/react-table'
export interface FilterPreset {
columnFilters: ColumnFiltersState
globalFilter?: string
id: string
name: string
}

View File

@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import type { FilterPreset } from './types';
export function useFilterPresets(persistenceKey?: string) {
const key = persistenceKey ?? 'default';
const [presets, setPresets] = useState<FilterPreset[]>(() => loadPresets(key));
const addPreset = useCallback(
(preset: Omit<FilterPreset, 'id'>) => {
setPresets((prev) => {
const next = [...prev, { ...preset, id: String(Date.now()) }];
savePresets(key, next);
return next;
});
},
[key]
);
const deletePreset = useCallback(
(id: string) => {
setPresets((prev) => {
const next = prev.filter((p) => p.id !== id);
savePresets(key, next);
return next;
});
},
[key]
);
return { addPreset, deletePreset, presets };
}
function getStorageKey(persistenceKey: string) {
return `griddy-filter-presets-${persistenceKey}`;
}
function loadPresets(persistenceKey: string): FilterPreset[] {
try {
const raw = localStorage.getItem(getStorageKey(persistenceKey));
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function savePresets(persistenceKey: string, presets: FilterPreset[]) {
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(presets));
}

View File

@@ -0,0 +1,37 @@
import type { Column } from '@tanstack/react-table';
import type React from 'react';
import { ActionIcon } from '@mantine/core';
import { IconFilter } from '@tabler/icons-react';
import { forwardRef } from 'react';
import { CSS } from '../../core/constants';
import styles from '../../styles/griddy.module.css';
interface ColumnFilterButtonProps {
column: Column<any, any>;
onClick?: (e: React.MouseEvent) => void;
}
export const ColumnFilterButton = forwardRef<HTMLButtonElement, ColumnFilterButtonProps>(
function ColumnFilterButton({ column, onClick, ...rest }, ref) {
const isActive = !!column.getFilterValue();
return (
<ActionIcon
{...rest}
aria-label="Open column filter"
className={[styles[CSS.filterButton], isActive ? styles[CSS.filterButtonActive] : '']
.filter(Boolean)
.join(' ')}
color={isActive ? 'blue' : 'gray'}
onClick={onClick}
ref={ref}
size="xs"
variant="subtle"
>
<IconFilter size={14} />
</ActionIcon>
);
}
);

View File

@@ -0,0 +1,105 @@
import type { Column } from '@tanstack/react-table'
import { Menu } from '@mantine/core'
import { IconFilter, IconSortAscending, IconTrash } from '@tabler/icons-react'
import { useState } from 'react'
interface HeaderContextMenuProps {
children: React.ReactNode
column: Column<any, any>
onOpenFilter: () => void
}
export function HeaderContextMenu({
children,
column,
onOpenFilter,
}: HeaderContextMenuProps) {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY })
}
const handleSort = () => {
if (column.getCanSort()) {
const handler = column.getToggleSortingHandler()
if (handler) {
handler({} as any)
}
}
setContextMenu(null)
}
const handleClearSort = () => {
if (column.getCanSort()) {
const handler = column.getToggleSortingHandler()
if (handler) {
handler({} as any)
}
}
setContextMenu(null)
}
const handleResetFilter = () => {
if (column.getCanFilter()) {
column.setFilterValue(undefined)
}
setContextMenu(null)
}
const handleOpenFilter = () => {
if (column.getCanFilter()) {
onOpenFilter()
}
setContextMenu(null)
}
return (
<>
<div onContextMenu={handleContextMenu}>
{children}
</div>
{contextMenu && (
<Menu
closeOnClickOutside
closeOnEscape
onClose={() => setContextMenu(null)}
opened={true}
position="bottom-start"
withinPortal
>
<Menu.Dropdown style={{ left: contextMenu.x, position: 'fixed', top: contextMenu.y }}>
{column.getCanSort() && (
<>
<Menu.Item leftSection={<IconSortAscending size={14} />} onClick={handleSort}>
Sort {column.getIsSorted() === 'asc' ? '↓' : '↑'}
</Menu.Item>
{column.getIsSorted() && (
<Menu.Item leftSection={<IconTrash size={14} />} onClick={handleClearSort}>
Reset Sorting
</Menu.Item>
)}
</>
)}
{column.getCanFilter() && (
<>
{column.getFilterValue() && (
<Menu.Item leftSection={<IconTrash size={14} />} onClick={handleResetFilter}>
Reset Filter
</Menu.Item>
)}
<Menu.Item leftSection={<IconFilter size={14} />} onClick={handleOpenFilter}>
Open Filters
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</>
)
}

View File

@@ -0,0 +1,145 @@
import type { Column } from '@tanstack/react-table';
import type React from 'react';
import { Button, Group, Popover, Stack, Text } from '@mantine/core';
import { useState } from 'react';
import type { FilterConfig, FilterValue } from './types';
import { getGriddyColumn } from '../../core/columnMapper';
import { QuickFilterDropdown } from '../quickFilter';
import { ColumnFilterButton } from './ColumnFilterButton';
import { FilterBoolean } from './FilterBoolean';
import { FilterDate } from './FilterDate';
import { FilterInput } from './FilterInput';
import { FilterSelect } from './FilterSelect';
import { OPERATORS_BY_TYPE } from './operators';
interface ColumnFilterPopoverProps {
column: Column<any, any>;
onOpenedChange?: (opened: boolean) => void;
opened?: boolean;
}
export function ColumnFilterPopover({
column,
onOpenedChange,
opened: externalOpened,
}: ColumnFilterPopoverProps) {
const [internalOpened, setInternalOpened] = useState(false);
// Support both internal and external control
const opened = externalOpened !== undefined ? externalOpened : internalOpened;
const setOpened = (value: boolean) => {
if (externalOpened !== undefined) {
onOpenedChange?.(value);
} else {
setInternalOpened(value);
}
};
const [localValue, setLocalValue] = useState<FilterValue | undefined>(
(column.getFilterValue() as FilterValue) || undefined
);
const griddyColumn = getGriddyColumn(column);
const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig;
if (!filterConfig) {
return null;
}
const handleApply = () => {
column.setFilterValue(localValue);
setOpened(false);
};
const handleClear = () => {
setLocalValue(undefined);
column.setFilterValue(undefined);
setOpened(false);
};
const handleClose = () => {
setOpened(false);
// Reset to previous value if popover is closed without applying
setLocalValue((column.getFilterValue() as FilterValue) || undefined);
};
const operators = filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type];
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
setOpened(!opened);
};
return (
<Popover onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
<Popover.Target>
<ColumnFilterButton column={column} onClick={handleToggle} />
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={280}>
<Text fw={600} size="sm">
Filter: {column.id}
</Text>
{filterConfig.type === 'text' && (
<FilterInput
onChange={setLocalValue}
operators={operators}
type="text"
value={localValue}
/>
)}
{filterConfig.type === 'number' && (
<FilterInput
onChange={setLocalValue}
operators={operators}
type="number"
value={localValue}
/>
)}
{filterConfig.type === 'enum' && filterConfig.enumOptions && (
<FilterSelect
onChange={setLocalValue}
operators={operators}
options={filterConfig.enumOptions}
value={localValue}
/>
)}
{filterConfig.type === 'boolean' && (
<FilterBoolean onChange={setLocalValue} value={localValue} />
)}
{filterConfig.type === 'date' && (
<FilterDate onChange={setLocalValue} operators={operators} value={localValue} />
)}
{filterConfig.quickFilter && (
<QuickFilterDropdown
column={column}
onApply={(val) => {
setLocalValue(val);
column.setFilterValue(val);
setOpened(false);
}}
value={localValue}
/>
)}
<Group justify="flex-end">
<Button onClick={handleClear} size="xs" variant="subtle">
Clear
</Button>
<Button onClick={handleApply} size="xs">
Apply
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -0,0 +1,32 @@
import { Radio, Stack } from '@mantine/core'
import { useState } from 'react'
import type { FilterValue } from './types'
interface FilterBooleanProps {
onChange: (value: FilterValue) => void
value?: FilterValue
}
export function FilterBoolean({ onChange, value }: FilterBooleanProps) {
const [operator, setOperator] = useState<string>(value?.operator || 'isEmpty')
const handleChange = (op: string) => {
setOperator(op)
onChange({
operator: op,
})
}
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Radio.Group label="Filter by" onChange={handleChange} value={operator}>
<Stack gap="xs">
<Radio label="True" value="isTrue" />
<Radio label="False" value="isFalse" />
<Radio label="All (no filter)" value="isEmpty" />
</Stack>
</Radio.Group>
</Stack>
)
}

View File

@@ -0,0 +1,109 @@
import { Group, Select, Stack } from '@mantine/core'
import { DatePickerInput } from '@mantine/dates'
import { useState } from 'react'
import type { FilterOperator, FilterValue } from './types'
interface FilterDateProps {
onChange: (value: FilterValue) => void
operators: FilterOperator[]
value?: FilterValue
}
export function FilterDate({ onChange, operators, value }: FilterDateProps) {
const [operator, setOperator] = useState<string>(value?.operator || operators[0]?.id || '')
const [startDate, setStartDate] = useState<Date | null>(() =>
value?.startDate ? new Date(value.startDate) : null
)
const [endDate, setEndDate] = useState<Date | null>(() =>
value?.endDate ? new Date(value.endDate) : null
)
const selectedOperator = operators.find((op) => op.id === operator)
const requiresValue = selectedOperator?.requiresValue !== false
const handleOperatorChange = (newOp: null | string) => {
if (newOp) {
setOperator(newOp)
}
}
// Handle "isBetween" operator specially
if (operator === 'isBetween') {
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
<Group grow>
<DatePickerInput
clearable
label="Start Date"
onChange={(date) => {
const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null
setStartDate(dateValue)
onChange({
endDate: endDate ?? undefined,
operator: 'isBetween',
startDate: dateValue ?? undefined,
})
}}
placeholder="Start date"
size="xs"
value={startDate}
/>
<DatePickerInput
clearable
label="End Date"
onChange={(date) => {
const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null
setEndDate(dateValue)
onChange({
endDate: dateValue ?? undefined,
operator: 'isBetween',
startDate: startDate ?? undefined,
})
}}
placeholder="End date"
size="xs"
value={endDate}
/>
</Group>
</Stack>
)
}
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
{requiresValue && (
<DatePickerInput
autoFocus
clearable
onChange={(date) => {
const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null
onChange({
operator,
value: dateValue ?? undefined,
})
}}
placeholder="Select date..."
size="xs"
value={value?.value ? new Date(value.value) : null}
/>
)}
</Stack>
)
}

View File

@@ -0,0 +1,141 @@
import { ActionIcon, Group, NumberInput, Select, Stack, TextInput } from '@mantine/core'
import { IconX } from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import type { FilterOperator, FilterValue } from './types'
interface FilterInputProps {
onChange: (value: FilterValue) => void
operators: FilterOperator[]
type: 'number' | 'text'
value?: FilterValue
}
export function FilterInput({ onChange, operators, type, value }: FilterInputProps) {
const [operator, setOperator] = useState<string>(value?.operator || operators[0]?.id || '')
const [inputValue, setInputValue] = useState<number | string | undefined>(
value?.value !== undefined && value?.value !== null ? value.value : undefined,
)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// Clear previous timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
// For text inputs, debounce the changes
if (type === 'text' && inputValue !== undefined && inputValue !== '') {
debounceTimerRef.current = setTimeout(() => {
onChange({
operator,
value: inputValue,
})
}, 300)
}
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
}, [inputValue, operator, onChange, type])
const selectedOperator = operators.find((op) => op.id === operator)
const requiresValue = selectedOperator?.requiresValue !== false
const handleClear = () => {
setInputValue(undefined)
}
const handleOperatorChange = (newOp: null | string) => {
if (newOp) {
setOperator(newOp)
}
}
// Handle "between" operator specially
if (operator === 'between' && type === 'number') {
const min = value?.min !== undefined ? Number(value.min) : undefined
const max = value?.max !== undefined ? Number(value.max) : undefined
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
<Group grow>
<NumberInput
label="Min"
onChange={(val) => {
onChange({
max: max === undefined ? undefined : Number(max),
min: val === undefined || val === null ? undefined : Number(val),
operator: 'between',
})
}}
placeholder="Minimum"
size="xs"
value={min}
/>
<NumberInput
label="Max"
onChange={(val) => {
onChange({
max: val === undefined || val === null ? undefined : Number(val),
min: min === undefined ? undefined : Number(min),
operator: 'between',
})
}}
placeholder="Maximum"
size="xs"
value={max}
/>
</Group>
</Stack>
)
}
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
{requiresValue && type === 'text' && (
<TextInput
autoFocus
onChange={(e) => setInputValue(e.currentTarget.value)}
placeholder="Enter value..."
rightSection={
inputValue && (
<ActionIcon color="gray" onClick={handleClear} size="xs" variant="subtle">
<IconX size={14} />
</ActionIcon>
)
}
size="xs"
value={inputValue === undefined ? '' : String(inputValue)}
/>
)}
{requiresValue && type === 'number' && (
<NumberInput
autoFocus
onChange={(val) => setInputValue(val)}
placeholder="Enter number..."
size="xs"
value={inputValue as number | undefined}
/>
)}
</Stack>
)
}

View File

@@ -0,0 +1,69 @@
import { MultiSelect, Select, Stack } from '@mantine/core'
import { useState } from 'react'
import type { FilterEnumOption, FilterOperator, FilterValue } from './types'
interface FilterSelectProps {
onChange: (value: FilterValue) => void
operators: FilterOperator[]
options: FilterEnumOption[]
value?: FilterValue
}
export function FilterSelect({ onChange, operators, options, value }: FilterSelectProps) {
const [operator, setOperator] = useState<string>(value?.operator || operators[0]?.id || 'includes')
const [selectedValues, setSelectedValues] = useState<string[]>(
value?.values?.map(String) || [],
)
const handleOperatorChange = (newOp: null | string) => {
if (newOp) {
setOperator(newOp)
}
}
const handleValuesChange = (vals: string[]) => {
setSelectedValues(vals)
if (operator !== 'isEmpty') {
onChange({
operator,
values: vals.map((v) => {
// Try to convert back to original value type
const option = options.find((opt) => String(opt.value) === v)
return option?.value ?? v
}),
})
}
}
const selectOptions = options.map((opt) => ({
label: opt.label,
value: String(opt.value),
}))
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
{operator !== 'isEmpty' && (
<MultiSelect
autoFocus
clearable
data={selectOptions}
label="Select values"
onChange={handleValuesChange}
placeholder="Choose values..."
searchable
size="xs"
value={selectedValues}
/>
)}
</Stack>
)
}

View File

@@ -0,0 +1,227 @@
import type { FilterFn } from '@tanstack/react-table'
import type { FilterValue } from './types'
// ─── Text Filter Functions ──────────────────────────────────────────────────
const textContains: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return String(value).toLowerCase().includes(String(filterValue.value).toLowerCase())
}
const textEquals: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return String(value).toLowerCase() === String(filterValue.value).toLowerCase()
}
const textStartsWith: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return String(value).toLowerCase().startsWith(String(filterValue.value).toLowerCase())
}
const textEndsWith: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return String(value).toLowerCase().endsWith(String(filterValue.value).toLowerCase())
}
const textNotContains: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return true
return !String(value).toLowerCase().includes(String(filterValue.value).toLowerCase())
}
const textIsEmpty: FilterFn<any> = (row: any, columnId: string) => {
const value = row.getValue(columnId)
return value == null || String(value).trim() === ''
}
const textIsNotEmpty: FilterFn<any> = (row: any, columnId: string) => {
const value = row.getValue(columnId)
return value != null && String(value).trim() !== ''
}
// ─── Number Filter Functions ────────────────────────────────────────────────
const numberEquals: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return Number(value) === Number(filterValue.value)
}
const numberNotEquals: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return Number(value) !== Number(filterValue.value)
}
const numberGreaterThan: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return Number(value) > Number(filterValue.value)
}
const numberGreaterThanOrEqual: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return Number(value) >= Number(filterValue.value)
}
const numberLessThan: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return Number(value) < Number(filterValue.value)
}
const numberLessThanOrEqual: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
return Number(value) <= Number(filterValue.value)
}
const numberBetween: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
const num = Number(value)
const min = filterValue.min != null ? Number(filterValue.min) : -Infinity
const max = filterValue.max != null ? Number(filterValue.max) : Infinity
return num >= min && num <= max
}
// ─── Enum Filter Functions ──────────────────────────────────────────────────
const enumIncludes: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
const values = filterValue.values || []
return values.includes(value)
}
const enumExcludes: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return true
const values = filterValue.values || []
return !values.includes(value)
}
// ─── Boolean Filter Functions ───────────────────────────────────────────────
const booleanIsTrue: FilterFn<any> = (row: any, columnId: string) => {
const value = row.getValue(columnId)
return value === true || value === 1 || String(value).toLowerCase() === 'true'
}
const booleanIsFalse: FilterFn<any> = (row: any, columnId: string) => {
const value = row.getValue(columnId)
return value === false || value === 0 || String(value).toLowerCase() === 'false'
}
// ─── Date Filter Functions ──────────────────────────────────────────────────
const dateIs: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null || filterValue.value == null) return false
const rowDate = new Date(value)
const filterDate = new Date(filterValue.value)
return rowDate.toDateString() === filterDate.toDateString()
}
const dateIsBefore: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null || filterValue.value == null) return false
const rowDate = new Date(value)
const filterDate = new Date(filterValue.value)
return rowDate < filterDate
}
const dateIsAfter: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null || filterValue.value == null) return false
const rowDate = new Date(value)
const filterDate = new Date(filterValue.value)
return rowDate > filterDate
}
const dateIsBetween: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
const value = row.getValue(columnId)
if (value == null) return false
const rowDate = new Date(value)
const startDate = filterValue.startDate ? new Date(filterValue.startDate) : null
const endDate = filterValue.endDate ? new Date(filterValue.endDate) : null
if (startDate && endDate) {
return rowDate >= startDate && rowDate <= endDate
}
if (startDate) {
return rowDate >= startDate
}
if (endDate) {
return rowDate <= endDate
}
return true
}
// ─── Filter Function Map ────────────────────────────────────────────────────
const FILTER_FN_MAP: Record<string, FilterFn<any>> = {
between: numberBetween,
contains: textContains,
endsWith: textEndsWith,
enumExcludes,
enumIncludes,
equals: ((row: any, columnId: string, filterValue: FilterValue, addMeta: any) => {
const value = row.getValue(columnId)
// Detect type and use appropriate equals function
if (typeof value === 'number') {
return numberEquals(row, columnId, filterValue, addMeta)
}
return textEquals(row, columnId, filterValue, addMeta)
}) as FilterFn<any>,
excludes: enumExcludes,
greaterThan: numberGreaterThan,
greaterThanOrEqual: numberGreaterThanOrEqual,
includes: enumIncludes,
is: dateIs,
isAfter: dateIsAfter,
isBefore: dateIsBefore,
isBetween: dateIsBetween,
isEmpty: (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(row: any, columnId: string, _filterValue: any, _addMeta: any) => {
const value = row.getValue(columnId)
return value == null || value === '' || (Array.isArray(value) && value.length === 0)
}
) as FilterFn<any>,
isFalse: booleanIsFalse,
isNotEmpty: (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(row: any, columnId: string, _filterValue: any, _addMeta: any) => {
const value = row.getValue(columnId)
return value != null && value !== '' && (!Array.isArray(value) || value.length > 0)
}
) as FilterFn<any>,
isTrue: booleanIsTrue,
lessThan: numberLessThan,
lessThanOrEqual: numberLessThanOrEqual,
notContains: textNotContains,
notEquals: numberNotEquals,
startsWith: textStartsWith,
textIsEmpty,
textIsNotEmpty,
}
// ─── Universal Filter Function ──────────────────────────────────────────────
export function createOperatorFilter(): FilterFn<any> {
return (row: any, columnId: string, filterValue: FilterValue, addMeta: any) => {
if (!filterValue?.operator) return true
const filterFn = FILTER_FN_MAP[filterValue.operator]
if (!filterFn) {
console.warn(`Unknown filter operator: ${filterValue.operator}`)
return true
}
return filterFn(row, columnId, filterValue, addMeta)
}
}

View File

@@ -0,0 +1,10 @@
export { ColumnFilterButton } from './ColumnFilterButton'
export { HeaderContextMenu } from './ColumnFilterContextMenu'
export { ColumnFilterPopover } from './ColumnFilterPopover'
export { FilterBoolean } from './FilterBoolean'
export { FilterDate } from './FilterDate'
export { createOperatorFilter } from './filterFunctions'
export { FilterInput } from './FilterInput'
export { FilterSelect } from './FilterSelect'
export { BOOLEAN_OPERATORS, DATE_OPERATORS, ENUM_OPERATORS, NUMBER_OPERATORS, OPERATORS_BY_TYPE, TEXT_OPERATORS } from './operators'
export type { FilterConfig, FilterEnumOption, FilterOperator, FilterState, FilterValue } from './types'

View File

@@ -0,0 +1,63 @@
import type { FilterOperator } from './types'
// ─── Text Operators ─────────────────────────────────────────────────────────
export const TEXT_OPERATORS: FilterOperator[] = [
{ id: 'contains', label: 'Contains' },
{ id: 'equals', label: 'Equals' },
{ id: 'startsWith', label: 'Starts with' },
{ id: 'endsWith', label: 'Ends with' },
{ id: 'notContains', label: 'Does not contain' },
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
{ id: 'isNotEmpty', label: 'Is not empty', requiresValue: false },
]
// ─── Number Operators ───────────────────────────────────────────────────────
export const NUMBER_OPERATORS: FilterOperator[] = [
{ id: 'equals', label: 'Equals (=)' },
{ id: 'notEquals', label: 'Not equals (≠)' },
{ id: 'greaterThan', label: 'Greater than (>)' },
{ id: 'greaterThanOrEqual', label: 'Greater or equal (≥)' },
{ id: 'lessThan', label: 'Less than (<)' },
{ id: 'lessThanOrEqual', label: 'Less or equal (≤)' },
{ id: 'between', label: 'Between' },
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
]
// ─── Enum Operators ─────────────────────────────────────────────────────────
export const ENUM_OPERATORS: FilterOperator[] = [
{ id: 'includes', label: 'Includes' },
{ id: 'excludes', label: 'Excludes' },
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
]
// ─── Boolean Operators ──────────────────────────────────────────────────────
export const BOOLEAN_OPERATORS: FilterOperator[] = [
{ id: 'isTrue', label: 'True' },
{ id: 'isFalse', label: 'False' },
{ id: 'isEmpty', label: 'All', requiresValue: false },
]
// ─── Date Operators ─────────────────────────────────────────────────────────
export const DATE_OPERATORS: FilterOperator[] = [
{ id: 'is', label: 'Is' },
{ id: 'isBefore', label: 'Is before' },
{ id: 'isAfter', label: 'Is after' },
{ id: 'isBetween', label: 'Is between' },
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
{ id: 'isNotEmpty', label: 'Is not empty', requiresValue: false },
]
// ─── Operator Maps ──────────────────────────────────────────────────────────
export const OPERATORS_BY_TYPE = {
boolean: BOOLEAN_OPERATORS,
date: DATE_OPERATORS,
enum: ENUM_OPERATORS,
number: NUMBER_OPERATORS,
text: TEXT_OPERATORS,
} as const

View File

@@ -0,0 +1,37 @@
// ─── Filter Types ───────────────────────────────────────────────────────────
export interface FilterConfig {
enumOptions?: FilterEnumOption[]
operators?: FilterOperator[]
/** Enable quick filter (checkbox list of unique values) in the filter popover */
quickFilter?: boolean
type: 'boolean' | 'date' | 'enum' | 'number' | 'text'
}
export interface FilterEnumOption {
label: string
value: any
}
export interface FilterOperator {
id: string
label: string
requiresValue?: boolean
}
// ─── Filter Value Structure ─────────────────────────────────────────────────
export interface FilterState {
id: string
value: FilterValue
}
export interface FilterValue {
endDate?: Date
max?: number
min?: number
operator: string
startDate?: Date
value?: any
values?: any[]
}

View File

@@ -0,0 +1,309 @@
import type { Table } from '@tanstack/react-table'
import type { Virtualizer } from '@tanstack/react-virtual'
import { type RefObject, useCallback, useEffect, useRef } from 'react'
import type { GriddyUIState, SearchConfig, SelectionConfig, TreeConfig } from '../../core/types'
interface UseKeyboardNavigationOptions<TData = unknown> {
editingEnabled: boolean
scrollRef: RefObject<HTMLDivElement | null>
search?: SearchConfig
selection?: SelectionConfig
storeState: GriddyUIState
table: Table<TData>
tree?: TreeConfig<TData>
virtualizer: Virtualizer<HTMLDivElement, Element>
}
export function useKeyboardNavigation<TData = unknown>({
editingEnabled,
scrollRef,
search,
selection,
storeState,
table,
tree,
virtualizer,
}: UseKeyboardNavigationOptions<TData>) {
// Keep a ref to the latest store state so the keydown handler always sees fresh state
const stateRef = useRef(storeState)
stateRef.current = storeState
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const state = stateRef.current
const { focusedRowIndex, isEditing, isSearchOpen } = state
const rowCount = table.getRowModel().rows.length
const visibleCount = virtualizer.getVirtualItems().length
const selectionMode = selection?.mode ?? 'none'
const multiSelect = selection?.mode === 'multi'
// ─── Search mode: only Escape exits ───
if (isSearchOpen) {
if (e.key === 'Escape') {
state.setSearchOpen(false)
e.preventDefault()
}
return
}
// ─── Edit mode: only Escape exits at grid level ───
if (isEditing) {
if (e.key === 'Escape') {
state.setEditing(false)
e.preventDefault()
}
return
}
// ─── Normal mode ───
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
// Handle shift+arrow before plain arrow
if (shift && !ctrl) {
if (e.key === 'ArrowDown' && multiSelect && focusedRowIndex !== null) {
e.preventDefault()
const nextIdx = Math.min(focusedRowIndex + 1, rowCount - 1)
const row = table.getRowModel().rows[nextIdx]
row?.toggleSelected(true)
state.moveFocus('down', 1)
virtualizer.scrollToIndex(Math.min(focusedRowIndex + 1, rowCount - 1), { align: 'auto' })
return
}
if (e.key === 'ArrowUp' && multiSelect && focusedRowIndex !== null) {
e.preventDefault()
const prevIdx = Math.max(focusedRowIndex - 1, 0)
const row = table.getRowModel().rows[prevIdx]
row?.toggleSelected(true)
state.moveFocus('up', 1)
virtualizer.scrollToIndex(Math.max(focusedRowIndex - 1, 0), { align: 'auto' })
return
}
}
let didNavigate: boolean
switch (e.key) {
case ' ': {
if (selectionMode !== 'none' && focusedRowIndex !== null) {
e.preventDefault()
const row = table.getRowModel().rows[focusedRowIndex]
if (row) {
if (selectionMode === 'single') {
table.resetRowSelection()
row.toggleSelected(true)
} else {
row.toggleSelected()
}
}
}
return
}
case 'a': {
if (ctrl && multiSelect) {
e.preventDefault()
table.toggleAllRowsSelected()
}
return
}
case 'ArrowDown': {
e.preventDefault()
state.moveFocus('down', 1)
didNavigate = true
break
}
case 'ArrowLeft': {
// Tree navigation: collapse or move to parent
if (tree?.enabled && focusedRowIndex !== null) {
e.preventDefault()
const row = table.getRowModel().rows[focusedRowIndex]
if (row) {
if (row.getIsExpanded()) {
// Collapse if expanded
row.toggleExpanded(false)
} else if (row.depth > 0) {
// Move to parent if not expanded
const parent = findParentRow(table.getRowModel().rows, row)
if (parent) {
const parentIndex = table.getRowModel().rows.findIndex((r) => r.id === parent.id)
if (parentIndex !== -1) {
state.setFocusedRow(parentIndex)
virtualizer.scrollToIndex(parentIndex, { align: 'auto' })
}
}
}
}
}
return
}
case 'ArrowRight': {
// Tree navigation: expand or move to first child
if (tree?.enabled && focusedRowIndex !== null) {
e.preventDefault()
const row = table.getRowModel().rows[focusedRowIndex]
if (row) {
if (row.getCanExpand() && !row.getIsExpanded()) {
// Expand if can expand and not already expanded
row.toggleExpanded(true)
} else if (row.getIsExpanded() && row.subRows.length > 0) {
// Move to first child if expanded
const nextIdx = focusedRowIndex + 1
if (nextIdx < rowCount) {
const nextRow = table.getRowModel().rows[nextIdx]
// Verify it's actually a child (depth increased)
if (nextRow && nextRow.depth > row.depth) {
state.setFocusedRow(nextIdx)
virtualizer.scrollToIndex(nextIdx, { align: 'auto' })
}
}
}
}
}
return
}
case 'ArrowUp': {
e.preventDefault()
state.moveFocus('up', 1)
didNavigate = true
break
}
case 'e': {
if (ctrl && editingEnabled && focusedRowIndex !== null) {
e.preventDefault()
// Find first editable column
const columns = table.getAllColumns().filter(col => col.id !== '_selection')
const firstEditableCol = columns.find(col => {
const meta = col.columnDef.meta as any
return meta?.griddy?.editable === true
})
if (firstEditableCol) {
state.setFocusedColumn(firstEditableCol.id)
}
state.setEditing(true)
}
return
}
case 'End': {
e.preventDefault()
state.moveFocusToEnd()
didNavigate = true
break
}
case 'Enter': {
if (editingEnabled && focusedRowIndex !== null && !ctrl) {
e.preventDefault()
// Find first editable column
const columns = table.getAllColumns().filter(col => col.id !== '_selection')
const firstEditableCol = columns.find(col => {
const meta = col.columnDef.meta as any
return meta?.griddy?.editable === true
})
if (firstEditableCol) {
state.setFocusedColumn(firstEditableCol.id)
}
state.setEditing(true)
}
return
}
case 'Escape': {
if (state.isSelecting) {
state.setSelecting(false)
e.preventDefault()
} else if (selectionMode !== 'none') {
table.resetRowSelection()
e.preventDefault()
}
return
}
case 'f': {
if (ctrl && search?.enabled) {
e.preventDefault()
state.setSearchOpen(true)
}
return
}
case 'Home': {
e.preventDefault()
state.moveFocusToStart()
didNavigate = true
break
}
case 'PageDown': {
e.preventDefault()
state.moveFocus('down', visibleCount)
didNavigate = true
break
}
case 'PageUp': {
e.preventDefault()
state.moveFocus('up', visibleCount)
didNavigate = true
break
}
case 's': {
if (ctrl && selectionMode !== 'none') {
e.preventDefault()
state.setSelecting(!state.isSelecting)
}
return
}
default:
return
}
// Auto-scroll after navigation keys
if (didNavigate && focusedRowIndex !== null) {
// Estimate the new position based on the action
const newIndex = Math.max(0, Math.min(
e.key === 'Home' ? 0 :
e.key === 'End' ? rowCount - 1 :
e.key === 'PageDown' ? focusedRowIndex + visibleCount :
e.key === 'PageUp' ? focusedRowIndex - visibleCount :
e.key === 'ArrowDown' ? focusedRowIndex + 1 :
focusedRowIndex - 1,
rowCount - 1,
))
virtualizer.scrollToIndex(newIndex, { align: 'auto' })
}
}, [table, virtualizer, selection, search, editingEnabled, tree])
useEffect(() => {
const el = scrollRef.current
if (!el) return
el.addEventListener('keydown', handleKeyDown)
return () => el.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown, scrollRef])
}
/**
* Helper to find parent row in tree structure
*/
function findParentRow<TData>(rows: any[], childRow: any): any | null {
const childIndex = rows.findIndex((r) => r.id === childRow.id);
if (childIndex === -1) return null;
const targetDepth = childRow.depth - 1;
// Search backwards from child position
for (let i = childIndex - 1; i >= 0; i--) {
if (rows[i].depth === targetDepth) {
return rows[i];
}
}
return null;
}

View File

@@ -0,0 +1,37 @@
import type { GriddyColumn } from '../../core/types';
import { DEFAULTS } from '../../core/constants';
import { useGriddyStore } from '../../core/GriddyStore';
import styles from '../../styles/griddy.module.css';
export function GriddyLoadingOverlay() {
return (
<div className={styles['griddy-loading-overlay']}>
<div className={styles['griddy-loading-spinner']}>Loading...</div>
</div>
);
}
export function GriddyLoadingSkeleton() {
const columns = useGriddyStore((s) => s.columns);
const rowHeight = useGriddyStore((s) => s.rowHeight) ?? DEFAULTS.rowHeight;
const skeletonRowCount = 8;
return (
<div className={styles['griddy-skeleton']}>
{Array.from({ length: skeletonRowCount }, (_, rowIndex) => (
<div className={styles['griddy-skeleton-row']} key={rowIndex} style={{ height: rowHeight }}>
{(columns ?? []).map((col: GriddyColumn<any>) => (
<div
className={styles['griddy-skeleton-cell']}
key={col.id}
style={{ width: col.width ?? 150 }}
>
<div className={styles['griddy-skeleton-bar']} />
</div>
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1 @@
export { GriddyLoadingOverlay, GriddyLoadingSkeleton } from './GriddyLoadingSkeleton'

View File

@@ -0,0 +1,81 @@
import type { Table } from '@tanstack/react-table'
import { ActionIcon, Group, Select, Text } from '@mantine/core'
import { IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react'
import styles from '../../styles/griddy.module.css'
interface PaginationControlProps<T> {
pageSizeOptions?: number[]
table: Table<T>
}
export function PaginationControl<T>({ pageSizeOptions = [10, 25, 50, 100], table }: PaginationControlProps<T>) {
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const pageCount = table.getPageCount()
const canPreviousPage = table.getCanPreviousPage()
const canNextPage = table.getCanNextPage()
return (
<Group className={styles['griddy-pagination']} gap="md" justify="space-between" p="xs">
<Group gap="xs">
<Text c="dimmed" size="sm">
Page {pageIndex + 1} of {pageCount}
</Text>
</Group>
<Group gap="xs">
<ActionIcon
disabled={!canPreviousPage}
onClick={() => table.setPageIndex(0)}
size="sm"
variant="subtle"
>
<IconChevronsLeft size={16} />
</ActionIcon>
<ActionIcon
disabled={!canPreviousPage}
onClick={() => table.previousPage()}
size="sm"
variant="subtle"
>
<IconChevronLeft size={16} />
</ActionIcon>
<ActionIcon
disabled={!canNextPage}
onClick={() => table.nextPage()}
size="sm"
variant="subtle"
>
<IconChevronRight size={16} />
</ActionIcon>
<ActionIcon
disabled={!canNextPage}
onClick={() => table.setPageIndex(pageCount - 1)}
size="sm"
variant="subtle"
>
<IconChevronsRight size={16} />
</ActionIcon>
</Group>
<Group gap="xs">
<Text c="dimmed" size="sm">
Rows per page:
</Text>
<Select
data={pageSizeOptions.map(size => ({ label: String(size), value: String(size) }))}
onChange={(value) => {
if (value) {
table.setPageSize(Number(value))
}
}}
size="xs"
value={String(pageSize)}
w={70}
/>
</Group>
</Group>
)
}

View File

@@ -0,0 +1 @@
export { PaginationControl } from './PaginationControl'

View File

@@ -0,0 +1,81 @@
import type { Column } from '@tanstack/react-table'
import { Checkbox, ScrollArea, Stack, Text, TextInput } from '@mantine/core'
import { useMemo, useState } from 'react'
import type { FilterValue } from '../filtering/types'
import { useGriddyStore } from '../../core/GriddyStore'
import styles from '../../styles/griddy.module.css'
interface QuickFilterDropdownProps {
column: Column<any, any>
onApply: (value: FilterValue | undefined) => void
value?: FilterValue
}
export function QuickFilterDropdown({ column, onApply, value }: QuickFilterDropdownProps) {
const data = useGriddyStore((s) => s.data) ?? []
const [searchTerm, setSearchTerm] = useState('')
const uniqueValues = useMemo(() => {
const seen = new Set<string>()
for (const row of data) {
const accessorFn = (column.columnDef as any).accessorFn
const cellValue = accessorFn
? accessorFn(row, 0)
: (row as any)[column.id]
const str = String(cellValue ?? '')
if (str) seen.add(str)
}
return Array.from(seen).sort()
}, [data, column])
const selectedValues = new Set<string>(value?.values ?? [])
const filtered = searchTerm
? uniqueValues.filter((v) => v.toLowerCase().includes(searchTerm.toLowerCase()))
: uniqueValues
const handleToggle = (val: string) => {
const next = new Set(selectedValues)
if (next.has(val)) {
next.delete(val)
} else {
next.add(val)
}
if (next.size === 0) {
onApply(undefined)
} else {
onApply({ operator: 'includes', values: Array.from(next) })
}
}
return (
<Stack className={styles['griddy-quick-filter']} gap="xs">
<Text fw={600} size="xs">Quick Filter</Text>
<TextInput
onChange={(e) => setSearchTerm(e.currentTarget.value)}
placeholder="Search values..."
size="xs"
value={searchTerm}
/>
<ScrollArea.Autosize mah={200}>
<Stack gap={4}>
{filtered.map((val) => (
<Checkbox
checked={selectedValues.has(val)}
key={val}
label={val}
onChange={() => handleToggle(val)}
size="xs"
/>
))}
{filtered.length === 0 && (
<Text c="dimmed" size="xs">No values found</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
)
}

View File

@@ -0,0 +1 @@
export { QuickFilterDropdown } from './QuickFilterDropdown'

View File

@@ -0,0 +1,24 @@
import type { RendererProps } from '../../core/types'
import styles from '../../styles/griddy.module.css'
interface BadgeMeta {
colorMap?: Record<string, string>
defaultColor?: string
}
export function BadgeRenderer<T>({ column, value }: RendererProps<T>) {
const meta = column.rendererMeta as BadgeMeta | undefined
const text = String(value ?? '')
const colorMap = meta?.colorMap ?? {}
const color = colorMap[text] ?? meta?.defaultColor ?? '#868e96'
return (
<span
className={styles['griddy-renderer-badge']}
style={{ background: color }}
>
{text}
</span>
)
}

View File

@@ -0,0 +1,27 @@
import type { RendererProps } from '../../core/types'
import styles from '../../styles/griddy.module.css'
interface ImageMeta {
alt?: string
height?: number
width?: number
}
export function ImageRenderer<T>({ column, value }: RendererProps<T>) {
const meta = column.rendererMeta as ImageMeta | undefined
const src = String(value ?? '')
const size = meta?.height ?? 28
if (!src) return null
return (
<img
alt={meta?.alt ?? ''}
className={styles['griddy-renderer-image']}
height={meta?.height ?? size}
src={src}
width={meta?.width ?? size}
/>
)
}

View File

@@ -0,0 +1,32 @@
import type { RendererProps } from '../../core/types'
import styles from '../../styles/griddy.module.css'
interface ProgressBarMeta {
color?: string
max?: number
showLabel?: boolean
}
export function ProgressBarRenderer<T>({ column, value }: RendererProps<T>) {
const meta = column.rendererMeta as ProgressBarMeta | undefined
const max = meta?.max ?? 100
const color = meta?.color ?? '#228be6'
const showLabel = meta?.showLabel !== false
const numValue = Number(value) || 0
const pct = Math.min(100, Math.max(0, (numValue / max) * 100))
return (
<div className={styles['griddy-renderer-progress']}>
<div
className={styles['griddy-renderer-progress-bar']}
style={{ background: color, width: `${pct}%` }}
/>
{showLabel && (
<span className={styles['griddy-renderer-progress-label']}>
{Math.round(pct)}%
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,45 @@
import type { RendererProps } from '../../core/types'
import styles from '../../styles/griddy.module.css'
interface SparklineMeta {
color?: string
height?: number
width?: number
}
export function SparklineRenderer<T>({ column, value }: RendererProps<T>) {
const meta = column.rendererMeta as SparklineMeta | undefined
const data = Array.isArray(value) ? value.map(Number) : []
const w = meta?.width ?? 80
const h = meta?.height ?? 24
const color = meta?.color ?? '#228be6'
if (data.length < 2) return null
const min = Math.min(...data)
const max = Math.max(...data)
const range = max - min || 1
const stepX = w / (data.length - 1)
const points = data
.map((v, i) => `${i * stepX},${h - ((v - min) / range) * h}`)
.join(' ')
return (
<svg
className={styles['griddy-renderer-sparkline']}
height={h}
viewBox={`0 0 ${w} ${h}`}
width={w}
>
<polyline
fill="none"
points={points}
stroke={color}
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
)
}

View File

@@ -0,0 +1,4 @@
export { BadgeRenderer } from './BadgeRenderer'
export { ImageRenderer } from './ImageRenderer'
export { ProgressBarRenderer } from './ProgressBarRenderer'
export { SparklineRenderer } from './SparklineRenderer'

View File

@@ -0,0 +1,111 @@
import { ActionIcon, Group, TextInput } from '@mantine/core'
import { IconX } from '@tabler/icons-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { CSS, DEFAULTS } from '../../core/constants'
import { useGriddyStore } from '../../core/GriddyStore'
import styles from '../../styles/griddy.module.css'
import { SearchHistoryDropdown, useSearchHistory } from '../searchHistory'
export function SearchOverlay() {
const table = useGriddyStore((s) => s._table)
const isSearchOpen = useGriddyStore((s) => s.isSearchOpen)
const setSearchOpen = useGriddyStore((s) => s.setSearchOpen)
const search = useGriddyStore((s) => s.search)
const persistenceKey = useGriddyStore((s) => s.persistenceKey)
const [query, setQuery] = useState('')
const [showHistory, setShowHistory] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<null | ReturnType<typeof setTimeout>>(null)
const { addEntry, clearHistory, history } = useSearchHistory(persistenceKey)
const debounceMs = search?.debounceMs ?? DEFAULTS.searchDebounceMs
const placeholder = search?.placeholder ?? 'Search...'
const closeSearch = useCallback(() => {
setSearchOpen(false)
setQuery('')
setShowHistory(false)
table?.setGlobalFilter(undefined)
}, [setSearchOpen, table])
useEffect(() => {
if (isSearchOpen) {
// Defer focus to next frame so the input is mounted
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [isSearchOpen])
const handleChange = useCallback((value: string) => {
setQuery(value)
setShowHistory(false)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
table?.setGlobalFilter(value || undefined)
if (value) addEntry(value)
}, debounceMs)
}, [table, debounceMs, addEntry])
// Handle Escape on the overlay container so it works regardless of which child has focus
const handleOverlayKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
closeSearch()
}
}, [closeSearch])
const handleFocus = useCallback(() => {
if (!query && history.length > 0) {
setShowHistory(true)
}
}, [query, history.length])
const handleSelectHistory = useCallback((q: string) => {
setQuery(q)
setShowHistory(false)
table?.setGlobalFilter(q)
}, [table])
if (!isSearchOpen) return null
return (
<div
className={styles[CSS.searchOverlay]}
onKeyDown={handleOverlayKeyDown}
>
<Group gap={4} wrap="nowrap">
<TextInput
aria-label="Search grid"
onChange={(e) => handleChange(e.currentTarget.value)}
onFocus={handleFocus}
placeholder={placeholder}
ref={inputRef}
size="xs"
value={query}
/>
<ActionIcon
aria-label="Close search"
onClick={closeSearch}
size="xs"
variant="subtle"
>
<IconX size={14} />
</ActionIcon>
</Group>
{showHistory && (
<SearchHistoryDropdown
history={history}
onClear={() => {
clearHistory()
setShowHistory(false)
}}
onSelect={handleSelectHistory}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { ActionIcon, Group, Text } from '@mantine/core'
import { IconTrash } from '@tabler/icons-react'
import styles from '../../styles/griddy.module.css'
interface SearchHistoryDropdownProps {
history: string[]
onClear: () => void
onSelect: (query: string) => void
}
export function SearchHistoryDropdown({ history, onClear, onSelect }: SearchHistoryDropdownProps) {
if (history.length === 0) return null
return (
<div className={styles['griddy-search-history']}>
<Group gap="xs" justify="space-between" mb={4}>
<Text c="dimmed" size="xs">Recent searches</Text>
<ActionIcon
color="gray"
onClick={onClear}
size="xs"
variant="subtle"
>
<IconTrash size={12} />
</ActionIcon>
</Group>
{history.map((query) => (
<button
className={styles['griddy-search-history-item']}
key={query}
onClick={() => onSelect(query)}
type="button"
>
{query}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { SearchHistoryDropdown } from './SearchHistoryDropdown'
export { useSearchHistory } from './useSearchHistory'

View File

@@ -0,0 +1,45 @@
import { useCallback, useState } from 'react';
const MAX_HISTORY = 10;
export function useSearchHistory(persistenceKey?: string) {
const key = persistenceKey ?? 'default';
const [history, setHistory] = useState<string[]>(() => loadHistory(key));
const addEntry = useCallback(
(query: string) => {
if (!query.trim()) return;
setHistory((prev) => {
const filtered = prev.filter((h) => h !== query);
const next = [query, ...filtered].slice(0, MAX_HISTORY);
saveHistory(key, next);
return next;
});
},
[key]
);
const clearHistory = useCallback(() => {
setHistory([]);
localStorage.removeItem(getStorageKey(key));
}, [key]);
return { addEntry, clearHistory, history };
}
function getStorageKey(persistenceKey: string) {
return `griddy-search-history-${persistenceKey}`;
}
function loadHistory(persistenceKey: string): string[] {
try {
const raw = localStorage.getItem(getStorageKey(persistenceKey));
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveHistory(persistenceKey: string, history: string[]) {
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(history));
}

View File

@@ -0,0 +1,53 @@
import type { Table } from '@tanstack/react-table'
import { ActionIcon, Group } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import { ColumnVisibilityMenu } from '../columnVisibility'
import { exportToCsv } from '../export'
import { FilterPresetsMenu } from '../filterPresets'
interface GridToolbarProps<T> {
exportFilename?: string
filterPresets?: boolean
persistenceKey?: string
showColumnToggle?: boolean
showExport?: boolean
table: Table<T>
}
export function GridToolbar<T>({
exportFilename = 'export.csv',
filterPresets = false,
persistenceKey,
showColumnToggle = true,
showExport = true,
table,
}: GridToolbarProps<T>) {
const handleExport = () => {
exportToCsv(table, exportFilename)
}
if (!showExport && !showColumnToggle && !filterPresets) {
return null
}
return (
<Group gap="xs" justify="flex-end" p="xs" style={{ borderBottom: '1px solid var(--griddy-border-color, #e0e0e0)' }}>
{filterPresets && (
<FilterPresetsMenu persistenceKey={persistenceKey} table={table} />
)}
{showExport && (
<ActionIcon
aria-label="Export to CSV"
onClick={handleExport}
size="sm"
variant="subtle"
>
<IconDownload size={16} />
</ActionIcon>
)}
{showColumnToggle && <ColumnVisibilityMenu table={table} />}
</Group>
)
}

View File

@@ -0,0 +1 @@
export { GridToolbar } from './GridToolbar'

View File

@@ -0,0 +1,70 @@
import type { ReactNode } from 'react';
import { Loader } from '@mantine/core';
import styles from '../../styles/griddy.module.css';
interface TreeExpandButtonProps {
canExpand: boolean;
icons?: {
collapsed?: ReactNode;
expanded?: ReactNode;
leaf?: ReactNode;
};
isExpanded: boolean;
isLoading?: boolean;
onToggle: () => void;
}
const DEFAULT_ICONS = {
collapsed: '\u25B6', // ►
expanded: '\u25BC', // ▼
leaf: null,
};
export function TreeExpandButton({
canExpand,
icons = DEFAULT_ICONS,
isExpanded,
isLoading = false,
onToggle,
}: TreeExpandButtonProps) {
const displayIcons = { ...DEFAULT_ICONS, ...icons };
// If loading, show spinner
if (isLoading) {
return (
<button
className={styles['griddy-tree-expand-button']}
disabled
style={{ cursor: 'wait' }}
type="button"
>
<Loader size="xs" />
</button>
);
}
// If can't expand (leaf node), show leaf icon or empty space
if (!canExpand) {
return (
<span className={styles['griddy-tree-expand-button']} style={{ cursor: 'default' }}>
{displayIcons.leaf || <span style={{ width: 20 }} />}
</span>
);
}
// Show expand/collapse icon
return (
<button
className={styles['griddy-tree-expand-button']}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
type="button"
>
{isExpanded ? displayIcons.expanded : displayIcons.collapsed}
</button>
);
}

View File

@@ -0,0 +1,9 @@
export {
hasChildren,
insertChildrenIntoData,
transformFlatToNested,
} from './transformTreeData';
export { TreeExpandButton } from './TreeExpandButton';
export { useAutoExpandOnSearch } from './useAutoExpandOnSearch';
export { useLazyTreeExpansion } from './useLazyTreeExpansion';
export { useTreeData } from './useTreeData';

View File

@@ -0,0 +1,138 @@
import type { TreeConfig } from '../../core/types';
/**
* Determines if a node has children (can be expanded)
* @param row - The data row
* @param config - Tree configuration
* @returns true if node has or can have children
*/
export function hasChildren<T>(row: any, config: TreeConfig<T>): boolean {
// If user provided hasChildren function, use it
if (config.hasChildren) {
return config.hasChildren(row);
}
// Check for children array
const childrenField = (config.childrenField as string) || 'children';
if (row[childrenField] && Array.isArray(row[childrenField])) {
return row[childrenField].length > 0;
}
// Check for subRows (TanStack Table convention)
if (row.subRows && Array.isArray(row.subRows)) {
return row.subRows.length > 0;
}
// Check for boolean flag (common pattern)
if (typeof row.hasChildren === 'boolean') {
return row.hasChildren;
}
// Check for childCount property
if (typeof row.childCount === 'number') {
return row.childCount > 0;
}
// Default: assume no children
return false;
}
/**
* Helper to insert children into data array at parent location
* Used by lazy loading to update the data array with fetched children
* @param data - Current data array
* @param parentId - ID of parent node
* @param children - Children to insert
* @param idField - Field name containing node ID
* @returns Updated data array with children inserted
*/
export function insertChildrenIntoData<T extends Record<string, any>>(
data: T[],
parentId: string,
children: T[],
idField: keyof T | string = 'id',
): T[] {
return data.map((item) => {
if (item[idField] === parentId) {
// Found the parent - add children as subRows
return { ...item, subRows: children };
}
// Recursively search in subRows
if (item.subRows && Array.isArray(item.subRows)) {
return {
...item,
subRows: insertChildrenIntoData(item.subRows, parentId, children, idField),
};
}
return item;
});
}
/**
* Transforms flat data with parentId references into nested tree structure
* @param data - Flat array of data with parent references
* @param parentIdField - Field name containing parent ID (default: 'parentId')
* @param idField - Field name containing node ID (default: 'id')
* @param maxDepth - Maximum tree depth to build (default: Infinity)
* @returns Array of root nodes with subRows property
*/
export function transformFlatToNested<T extends Record<string, any>>(
data: T[],
parentIdField: keyof T | string = 'parentId',
idField: keyof T | string = 'id',
maxDepth = Infinity,
): T[] {
// Build a map of id -> node for quick lookups
const nodeMap = new Map<any, { subRows?: T[] } & T>();
const roots: ({ subRows?: T[] } & T)[] = [];
// First pass: create map of all nodes
data.forEach((item) => {
nodeMap.set(item[idField], { ...item, subRows: [] });
});
// Second pass: build tree structure
data.forEach((item) => {
const node = nodeMap.get(item[idField])!;
const parentId = item[parentIdField];
if (parentId == null || parentId === '') {
// Root node (no parent or empty parent)
roots.push(node);
} else {
const parent = nodeMap.get(parentId);
if (parent) {
// Add to parent's children
if (!parent.subRows) {
parent.subRows = [];
}
parent.subRows.push(node);
} else {
// Orphaned node (parent doesn't exist) - treat as root
roots.push(node);
}
}
});
// Enforce max depth by removing children beyond the limit
if (maxDepth !== Infinity) {
const enforceDepth = (nodes: ({ subRows?: T[] } & T)[], currentDepth: number) => {
if (currentDepth >= maxDepth) {
return;
}
nodes.forEach((node) => {
if (node.subRows && node.subRows.length > 0) {
if (currentDepth + 1 >= maxDepth) {
// Remove children at max depth
delete node.subRows;
} else {
enforceDepth(node.subRows, currentDepth + 1);
}
}
});
};
enforceDepth(roots, 0);
}
return roots;
}

View File

@@ -0,0 +1,87 @@
import type { Table } from '@tanstack/react-table';
import { useEffect, useRef } from 'react';
import type { TreeConfig } from '../../core/types';
interface UseAutoExpandOnSearchOptions<TData> {
globalFilter?: string;
table: Table<TData>;
tree?: TreeConfig<TData>;
}
/**
* Hook to auto-expand parent nodes when search matches child nodes
*/
export function useAutoExpandOnSearch<TData>({
globalFilter: globalFilterProp,
table,
tree,
}: UseAutoExpandOnSearchOptions<TData>) {
const previousFilterRef = useRef<string | undefined>(undefined);
const previousExpandedRef = useRef<Record<string, boolean>>({});
useEffect(() => {
// Only handle if tree is enabled and autoExpandOnSearch is not disabled
if (!tree?.enabled || tree.autoExpandOnSearch === false) {
return;
}
const globalFilter = table.getState().globalFilter;
const previousFilter = previousFilterRef.current;
// Update ref
previousFilterRef.current = globalFilter;
// If filter was cleared, optionally restore previous expanded state
if (!globalFilter && previousFilter) {
// Filter was cleared - leave expanded state as-is
return;
}
// If no filter or filter unchanged, skip
if (!globalFilter || globalFilter === previousFilter) {
return;
}
// Use flatRows to get all rows at all depths in the filtered model
const filteredFlatRows = table.getFilteredRowModel().flatRows;
if (filteredFlatRows.length === 0) {
return;
}
// Build set of all ancestors that should be expanded
const toExpand: Record<string, boolean> = {};
// Build a lookup map from flatRows for fast parent resolution
const rowById = new Map<string, (typeof filteredFlatRows)[0]>();
filteredFlatRows.forEach((row) => rowById.set(row.id, row));
filteredFlatRows.forEach((row) => {
// If row has depth > 0, walk up parent chain and expand all ancestors
if (row.depth > 0) {
let current = row;
while (current.parentId) {
toExpand[current.parentId] = true;
const parent = rowById.get(current.parentId);
if (!parent) break;
current = parent;
}
}
});
// Merge with current expanded state
const currentExpanded = table.getState().expanded;
const newExpanded = { ...currentExpanded, ...toExpand };
// Only update if there are changes
if (Object.keys(toExpand).length > 0) {
// Save previous expanded state before search (for potential restore)
if (!previousFilter) {
previousExpandedRef.current = currentExpanded;
}
table.setExpanded(newExpanded);
}
}, [tree, table, globalFilterProp]);
}

View File

@@ -0,0 +1,104 @@
import type { Table } from '@tanstack/react-table';
import { useEffect, useRef } from 'react';
import type { TreeConfig } from '../../core/types';
import { insertChildrenIntoData } from './transformTreeData';
interface UseLazyTreeExpansionOptions<T> {
data: T[];
expanded: Record<string, boolean> | true;
setData: (data: T[]) => void;
setTreeChildrenCache: (nodeId: string, children: T[]) => void;
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
table: Table<T>;
tree?: TreeConfig<T>;
treeChildrenCache: Map<string, T[]>;
}
/**
* Hook to handle lazy loading of tree children when nodes are expanded
*/
export function useLazyTreeExpansion<T extends Record<string, any>>({
data,
expanded: expandedState,
setData,
setTreeChildrenCache,
setTreeLoadingNode,
table,
tree,
treeChildrenCache,
}: UseLazyTreeExpansionOptions<T>) {
const expandedRef = useRef<Record<string, boolean>>({});
useEffect(() => {
// Only handle lazy mode
if (!tree?.enabled || tree.mode !== 'lazy' || !tree.getChildren) {
return;
}
const expanded = typeof expandedState === 'object' ? expandedState : {};
const previousExpanded = expandedRef.current;
// Find newly expanded nodes
const newlyExpanded = Object.keys(expanded).filter(
(id) => expanded[id] && !previousExpanded[id]
);
// Update ref
expandedRef.current = { ...expanded };
if (newlyExpanded.length === 0) {
return;
}
// For each newly expanded node, check if children need to be loaded
newlyExpanded.forEach(async (rowId) => {
// Check if children already loaded
const row = table.getRowModel().rows.find((r) => r.id === rowId);
if (!row) return;
const hasSubRows = row.subRows && row.subRows.length > 0;
const hasCachedChildren = treeChildrenCache.has(rowId);
// If children already loaded or cached, skip
if (hasSubRows || hasCachedChildren) {
if (hasCachedChildren && !hasSubRows) {
// Apply cached children to data
const cached = treeChildrenCache.get(rowId)!;
const updatedData = insertChildrenIntoData(data, rowId, cached);
setData(updatedData);
}
return;
}
// Load children
try {
setTreeLoadingNode(rowId, true);
const children = await Promise.resolve(tree.getChildren!(row.original));
// Cache children
setTreeChildrenCache(rowId, children);
// Insert children into data
const updatedData = insertChildrenIntoData(data, rowId, children);
setData(updatedData);
} catch (error) {
console.error('Failed to load tree children:', error);
// Optionally: trigger error callback or toast
} finally {
setTreeLoadingNode(rowId, false);
}
});
}, [
tree,
table,
expandedState,
data,
setData,
setTreeLoadingNode,
setTreeChildrenCache,
treeChildrenCache,
]);
}

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import type { TreeConfig } from '../../core/types';
import { transformFlatToNested } from './transformTreeData';
/**
* Hook to transform data based on tree mode
* @param data - Raw data array
* @param tree - Tree configuration
* @returns Transformed data ready for TanStack Table
*/
export function useTreeData<T extends Record<string, any>>(
data: T[],
tree?: TreeConfig<T>,
): T[] {
return useMemo(() => {
if (!tree?.enabled || !data) {
return data;
}
const mode = tree.mode || 'nested';
switch (mode) {
case 'flat': {
// Transform flat data with parentId to nested structure
const parentIdField = tree.parentIdField || 'parentId';
const idField = 'id'; // Assume 'id' field exists
const maxDepth = tree.maxDepth || Infinity;
return transformFlatToNested(data, parentIdField, idField, maxDepth);
}
case 'lazy': {
// Lazy mode: data is already structured, children loaded on-demand
// Just return data as-is, lazy loading hook will handle expansion
return data;
}
case 'nested': {
// If childrenField is not 'subRows', map it
const childrenField = (tree.childrenField as string) || 'children';
if (childrenField === 'subRows' || childrenField === 'children') {
// Already in correct format or standard format
return data.map((item) => {
if (childrenField === 'children' && item.children) {
return { ...item, subRows: item.children };
}
return item;
});
}
// Map custom children field to subRows
return data.map((item) => {
if (item[childrenField]) {
return { ...item, subRows: item[childrenField] };
}
return item;
});
}
default:
return data;
}
}, [data, tree]);
}

48
src/Griddy/index.ts Normal file
View File

@@ -0,0 +1,48 @@
// Adapter exports
export {
applyCursor,
buildOptions,
HeaderSpecAdapter,
mapFilters,
mapPagination,
mapSorting,
ResolveSpecAdapter,
} from './adapters';
export type { AdapterConfig, AdapterRef } from './adapters';
export { getGriddyColumn, mapColumns } from './core/columnMapper';
export { CSS, DEFAULTS, SELECTION_COLUMN_ID } from './core/constants';
export { Griddy } from './core/Griddy';
export { GriddyProvider, useGriddyStore } from './core/GriddyStore';
export type { GriddyStoreState } from './core/GriddyStore';
export type {
AdvancedSearchConfig,
CellRenderer,
DataAdapter,
EditorComponent,
EditorProps,
FetchConfig,
GriddyColumn,
GriddyDataSource,
GriddyProps,
GriddyRef,
GriddyUIState,
GroupingConfig,
PaginationConfig,
RendererProps,
SearchConfig,
SelectionConfig,
} from './core/types';
// Feature exports
export { GriddyErrorBoundary } from './features/errorBoundary';
export { FilterPresetsMenu, useFilterPresets } from './features/filterPresets';
export type { FilterPreset } from './features/filterPresets';
export { GriddyLoadingOverlay, GriddyLoadingSkeleton } from './features/loading';
export {
BadgeRenderer,
ImageRenderer,
ProgressBarRenderer,
SparklineRenderer,
} from './features/renderers';
export { useSearchHistory } from './features/searchHistory';

1402
src/Griddy/plan.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
import type { Cell } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react';
import type { EditorConfig } from '../editors';
import { getGriddyColumn } from '../core/columnMapper';
import { CheckboxEditor, DateEditor, NumericEditor, SelectEditor, TextEditor } from '../editors';
interface EditableCellProps<T> {
cell: Cell<T, unknown>;
isEditing: boolean;
onCancelEdit: () => void;
onCommitEdit: (value: unknown) => void;
onMoveNext?: () => void;
onMovePrev?: () => void;
}
export function EditableCell<T>({
cell,
isEditing,
onCancelEdit,
onCommitEdit,
onMoveNext,
onMovePrev,
}: EditableCellProps<T>) {
const griddyColumn = getGriddyColumn(cell.column);
const editorConfig: EditorConfig = (griddyColumn as any)?.editorConfig ?? {};
const customEditor = (griddyColumn as any)?.editor;
const [value, setValue] = useState(cell.getValue());
useEffect(() => {
setValue(cell.getValue());
}, [cell]);
const handleCommit = useCallback(
(newValue: unknown) => {
setValue(newValue);
onCommitEdit(newValue);
},
[onCommitEdit]
);
const handleCancel = useCallback(() => {
setValue(cell.getValue());
onCancelEdit();
}, [cell, onCancelEdit]);
if (!isEditing) {
return null;
}
// Custom editor from column definition
if (customEditor) {
const EditorComponent = customEditor as any;
return (
<EditorComponent
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value}
/>
);
}
// Built-in editors based on editorConfig.type
const editorType = editorConfig.type ?? 'text';
switch (editorType) {
case 'checkbox':
return (
<CheckboxEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as boolean}
/>
);
case 'date':
return (
<DateEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as Date | string}
/>
);
case 'number':
return (
<NumericEditor
max={editorConfig.max}
min={editorConfig.min}
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
step={editorConfig.step}
value={value as number}
/>
);
case 'select':
return (
<SelectEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
options={editorConfig.options ?? []}
value={value}
/>
);
case 'text':
default:
return (
<TextEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as string}
/>
);
}
}

View File

@@ -0,0 +1,180 @@
import { Checkbox } from '@mantine/core';
import { type Cell, flexRender } from '@tanstack/react-table';
import { getGriddyColumn } from '../core/columnMapper';
import { CSS, SELECTION_COLUMN_ID } from '../core/constants';
import { useGriddyStore } from '../core/GriddyStore';
import { TreeExpandButton } from '../features/tree/TreeExpandButton';
import styles from '../styles/griddy.module.css';
import { EditableCell } from './EditableCell';
interface TableCellProps<T> {
cell: Cell<T, unknown>;
showGrouping?: boolean;
}
export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID;
const isEditing = useGriddyStore((s) => s.isEditing);
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex);
const focusedColumnId = useGriddyStore((s) => s.focusedColumnId);
const setEditing = useGriddyStore((s) => s.setEditing);
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn);
const onEditCommit = useGriddyStore((s) => s.onEditCommit);
const tree = useGriddyStore((s) => s.tree);
const treeLoadingNodes = useGriddyStore((s) => s.treeLoadingNodes);
const selection = useGriddyStore((s) => s.selection);
if (isSelectionCol) {
return <RowCheckbox cell={cell} />;
}
const griddyColumn = getGriddyColumn(cell.column);
const rowIndex = cell.row.index;
const columnId = cell.column.id;
const isEditable = (griddyColumn as any)?.editable ?? false;
const isFocusedCell = isEditing && focusedRowIndex === rowIndex && focusedColumnId === columnId;
const handleCommit = async (value: unknown) => {
if (onEditCommit) {
await onEditCommit(cell.row.id, columnId, value);
}
setEditing(false);
setFocusedColumn(null);
};
const handleCancel = () => {
setEditing(false);
setFocusedColumn(null);
};
const handleDoubleClick = () => {
if (isEditable) {
setEditing(true);
setFocusedColumn(columnId);
}
};
const isPinned = cell.column.getIsPinned();
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined;
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined;
const isGrouped = cell.getIsGrouped();
const isAggregated = cell.getIsAggregated();
const isPlaceholder = cell.getIsPlaceholder();
// Tree support
const depth = cell.row.depth;
const canExpand =
cell.row.getCanExpand() ||
(tree?.enabled && tree?.mode === 'lazy' && tree?.hasChildren?.(cell.row.original as any)) ||
false;
const isExpanded = cell.row.getIsExpanded();
const hasSelection = selection != null && selection.mode !== 'none';
const columnIndex = cell.column.getIndex();
// First content column is index 0 if no selection, or index 1 if selection enabled
const isFirstColumn = hasSelection ? columnIndex === 1 : columnIndex === 0;
const indentSize = tree?.indentSize ?? 20;
const showTreeButton = tree?.enabled && isFirstColumn && tree?.showExpandIcon !== false;
return (
<div
className={[
styles[CSS.cell],
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
]
.filter(Boolean)
.join(' ')}
onDoubleClick={handleDoubleClick}
role="gridcell"
style={{
left: leftOffset !== undefined ? `${leftOffset}px` : undefined,
paddingLeft: isFirstColumn && tree?.enabled ? `${depth * indentSize + 8}px` : undefined,
position: isPinned ? 'sticky' : 'relative',
right: rightOffset !== undefined ? `${rightOffset}px` : undefined,
width: cell.column.getSize(),
zIndex: isPinned ? 1 : 0,
}}
>
{showTreeButton && (
<TreeExpandButton
canExpand={canExpand}
icons={tree?.icons}
isExpanded={isExpanded}
isLoading={treeLoadingNodes.has(cell.row.id)}
onToggle={() => cell.row.toggleExpanded()}
/>
)}
{showGrouping && isGrouped && (
<button
onClick={() => cell.row.toggleExpanded()}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
marginRight: 4,
padding: 0,
}}
>
{cell.row.getIsExpanded() ? '\u25BC' : '\u25B6'}
</button>
)}
{isFocusedCell && isEditable ? (
<EditableCell
cell={cell}
isEditing={isFocusedCell}
onCancelEdit={handleCancel}
onCommitEdit={handleCommit}
/>
) : isGrouped ? (
<>
{flexRender(cell.column.columnDef.cell, cell.getContext())} ({cell.row.subRows.length})
</>
) : isAggregated ? (
flexRender(
cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,
cell.getContext()
)
) : isPlaceholder ? null : (
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
</div>
);
}
function RowCheckbox<T>({ cell }: TableCellProps<T>) {
const row = cell.row;
const isPinned = cell.column.getIsPinned();
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined;
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined;
return (
<div
className={[
styles[CSS.cell],
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
]
.filter(Boolean)
.join(' ')}
role="gridcell"
style={{
left: leftOffset !== undefined ? `${leftOffset}px` : undefined,
position: isPinned ? 'sticky' : 'relative',
right: rightOffset !== undefined ? `${rightOffset}px` : undefined,
width: cell.column.getSize(),
zIndex: isPinned ? 1 : 0,
}}
>
<Checkbox
aria-label={`Select row ${row.index + 1}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => e.stopPropagation()}
size="xs"
/>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { Checkbox } from '@mantine/core';
import { flexRender } from '@tanstack/react-table';
import { useState } from 'react';
import { CSS, SELECTION_COLUMN_ID } from '../core/constants';
import { useGriddyStore } from '../core/GriddyStore';
import { ColumnFilterPopover, HeaderContextMenu } from '../features/filtering';
import styles from '../styles/griddy.module.css';
export function TableHeader() {
const table = useGriddyStore((s) => s._table);
const [filterPopoverOpen, setFilterPopoverOpen] = useState<null | string>(null);
const [draggedColumn, setDraggedColumn] = useState<null | string>(null);
if (!table) return null;
const headerGroups = table.getHeaderGroups();
const handleDragStart = (e: React.DragEvent, columnId: string) => {
setDraggedColumn(columnId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', columnId);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
e.preventDefault();
if (!draggedColumn || draggedColumn === targetColumnId) {
setDraggedColumn(null);
return;
}
const columnOrder = table.getState().columnOrder;
const currentOrder = columnOrder.length
? columnOrder
: table.getAllLeafColumns().map((c) => c.id);
const draggedIdx = currentOrder.indexOf(draggedColumn);
const targetIdx = currentOrder.indexOf(targetColumnId);
if (draggedIdx === -1 || targetIdx === -1) {
setDraggedColumn(null);
return;
}
const newOrder = [...currentOrder];
newOrder.splice(draggedIdx, 1);
newOrder.splice(targetIdx, 0, draggedColumn);
table.setColumnOrder(newOrder);
setDraggedColumn(null);
};
const handleDragEnd = () => {
setDraggedColumn(null);
};
return (
<div className={styles[CSS.thead]} role="rowgroup">
{headerGroups.map((headerGroup) => (
<div className={styles[CSS.headerRow]} key={headerGroup.id} role="row">
{headerGroup.headers.map((header) => {
const isSortable = header.column.getCanSort();
const sortDir = header.column.getIsSorted();
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID;
const isFilterPopoverOpen = filterPopoverOpen === header.column.id;
const isPinned = header.column.getIsPinned();
const leftOffset = isPinned === 'left' ? header.getStart('left') : undefined;
const rightOffset = isPinned === 'right' ? header.column.getAfter('right') : undefined;
const isDragging = draggedColumn === header.column.id;
const canReorder = !isSelectionCol && !isPinned;
return (
<div
aria-sort={
sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'
}
className={[
styles[CSS.headerCell],
isSortable ? styles[CSS.headerCellSortable] : '',
sortDir ? styles[CSS.headerCellSorted] : '',
isPinned === 'left' ? styles['griddy-header-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-header-cell--pinned-right'] : '',
isDragging ? styles['griddy-header-cell--dragging'] : '',
]
.filter(Boolean)
.join(' ')}
draggable={canReorder}
key={header.id}
onClick={isSortable ? header.column.getToggleSortingHandler() : undefined}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={(e) => canReorder && handleDragStart(e, header.column.id)}
onDrop={(e) => canReorder && handleDrop(e, header.column.id)}
role="columnheader"
style={{
cursor: canReorder ? 'move' : undefined,
left: leftOffset !== undefined ? `${leftOffset}px` : undefined,
opacity: isDragging ? 0.5 : 1,
position: isPinned ? 'sticky' : 'relative',
right: rightOffset !== undefined ? `${rightOffset}px` : undefined,
width: header.getSize(),
zIndex: isPinned ? 2 : 1,
}}
>
{isSelectionCol ? (
<SelectAllCheckbox />
) : header.isPlaceholder ? null : (
<HeaderContextMenu
column={header.column}
onOpenFilter={() => setFilterPopoverOpen(header.column.id)}
>
<div className={styles[CSS.headerCellContent]}>
{flexRender(header.column.columnDef.header, header.getContext())}
{sortDir && (
<span className={styles[CSS.sortIndicator]}>
{sortDir === 'asc' ? ' \u2191' : ' \u2193'}
</span>
)}
{header.column.getCanFilter() && (
<ColumnFilterPopover
column={header.column}
onOpenedChange={(opened) =>
setFilterPopoverOpen(opened ? header.column.id : null)
}
opened={isFilterPopoverOpen}
/>
)}
</div>
</HeaderContextMenu>
)}
{header.column.getCanResize() && (
<div
className={styles[CSS.resizeHandle]}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
)}
</div>
);
})}
</div>
))}
</div>
);
}
function SelectAllCheckbox() {
const table = useGriddyStore((s) => s._table);
const selection = useGriddyStore((s) => s.selection);
if (!table || !selection || selection.mode !== 'multi') return null;
return (
<Checkbox
aria-label="Select all rows"
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
size="xs"
/>
);
}

View File

@@ -0,0 +1,69 @@
import type { Row } from '@tanstack/react-table'
import { useCallback } from 'react'
import { CSS } from '../core/constants'
import { useGriddyStore } from '../core/GriddyStore'
import styles from '../styles/griddy.module.css'
import { TableCell } from './TableCell'
interface TableRowProps<T> {
row: Row<T>
size: number
start: number
}
export function TableRow<T>({ row, size, start }: TableRowProps<T>) {
const selection = useGriddyStore((s) => s.selection)
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex)
const setFocusedRow = useGriddyStore((s) => s.setFocusedRow)
const isFocused = focusedRowIndex === row.index
const isSelected = row.getIsSelected()
const isEven = row.index % 2 === 0
const handleClick = useCallback(() => {
setFocusedRow(row.index)
if (selection && selection.mode !== 'none' && selection.selectOnClick !== false) {
if (selection.mode === 'single') {
row.toggleSelected(true)
} else {
row.toggleSelected()
}
}
}, [row, selection, setFocusedRow])
const classNames = [
styles[CSS.row],
isFocused ? styles[CSS.rowFocused] : '',
isSelected ? styles[CSS.rowSelected] : '',
isEven ? styles[CSS.rowEven] : '',
!isEven ? styles[CSS.rowOdd] : '',
row.getIsGrouped() ? styles['griddy-row--grouped'] : '',
].filter(Boolean).join(' ')
return (
<div
aria-rowindex={row.index + 1}
aria-selected={isSelected}
className={classNames}
id={`griddy-row-${row.id}`}
onClick={handleClick}
role="row"
style={{
display: 'flex',
height: size,
left: 0,
position: 'absolute',
top: 0,
transform: `translateY(${start}px)`,
width: '100%',
}}
>
{row.getVisibleCells().map((cell, index) => (
<TableCell cell={cell} key={cell.id} showGrouping={index === 0} />
))}
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useRef } from 'react';
import { CSS } from '../core/constants';
import { useGriddyStore } from '../core/GriddyStore';
import styles from '../styles/griddy.module.css';
import { TableRow } from './TableRow';
export function VirtualBody() {
const table = useGriddyStore((s) => s._table);
const virtualizer = useGriddyStore((s) => s._virtualizer);
const setTotalRows = useGriddyStore((s) => s.setTotalRows);
const infiniteScroll = useGriddyStore((s) => s.infiniteScroll);
const rows = table?.getRowModel().rows;
const virtualRows = virtualizer?.getVirtualItems();
const totalSize = virtualizer?.getTotalSize() ?? 0;
// Track if we're currently loading to prevent multiple simultaneous calls
const isLoadingRef = useRef(false);
// Sync row count to store for keyboard navigation bounds
useEffect(() => {
if (rows) {
setTotalRows(rows.length);
}
}, [rows?.length, setTotalRows]);
// Infinite scroll: detect when approaching the end
useEffect(() => {
if (!infiniteScroll?.enabled || !infiniteScroll.onLoadMore || !virtualRows || !rows) {
return;
}
const { hasMore = true, isLoading = false, threshold = 10 } = infiniteScroll;
// Don't trigger if already loading or no more data
if (isLoading || !hasMore || isLoadingRef.current) {
return;
}
// Check if the last rendered virtual row is within threshold of the end
const lastVirtualRow = virtualRows[virtualRows.length - 1];
if (!lastVirtualRow) return;
const lastVirtualIndex = lastVirtualRow.index;
const totalRows = rows.length;
const distanceFromEnd = totalRows - lastVirtualIndex - 1;
if (distanceFromEnd <= threshold) {
isLoadingRef.current = true;
const loadPromise = infiniteScroll.onLoadMore();
if (loadPromise instanceof Promise) {
loadPromise.finally(() => {
isLoadingRef.current = false;
});
} else {
isLoadingRef.current = false;
}
}
}, [virtualRows, rows, infiniteScroll]);
if (!table || !virtualizer || !rows || !virtualRows) return null;
const showLoadingIndicator = infiniteScroll?.enabled && infiniteScroll.isLoading;
return (
<div
className={styles[CSS.tbody]}
role="rowgroup"
style={{
height: totalSize,
position: 'relative',
width: '100%',
}}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return <TableRow key={row.id} row={row} size={virtualRow.size} start={virtualRow.start} />;
})}
{showLoadingIndicator && (
<div
className={styles['griddy-loading-indicator']}
style={{
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
}}
>
<div className={styles['griddy-loading-spinner']}>Loading more...</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { Table } from '@tanstack/react-table'
import type { RefObject } from 'react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import { DEFAULTS } from '../../core/constants'
interface UseGridVirtualizerOptions {
overscan?: number
rowHeight?: number
scrollRef: RefObject<HTMLDivElement | null>
table: Table<any>
}
export function useGridVirtualizer({
overscan = DEFAULTS.overscan,
rowHeight = DEFAULTS.rowHeight,
scrollRef,
table,
}: UseGridVirtualizerOptions): Virtualizer<HTMLDivElement, Element> {
const rowCount = table.getRowModel().rows.length
return useVirtualizer({
count: rowCount,
estimateSize: () => rowHeight,
getScrollElement: () => scrollRef.current,
overscan,
})
}

View File

@@ -0,0 +1,654 @@
/* ─── Root ──────────────────────────────────────────────────────────────── */
.griddy {
--griddy-font-family: inherit;
--griddy-font-size: 14px;
--griddy-border-color: #e0e0e0;
--griddy-header-bg: #f8f9fa;
--griddy-header-color: #212529;
--griddy-row-bg: #ffffff;
--griddy-row-hover-bg: #f1f3f5;
--griddy-row-even-bg: #f8f9fa;
--griddy-focus-color: #228be6;
--griddy-selection-bg: rgba(34, 139, 230, 0.1);
--griddy-cell-padding: 0 8px;
--griddy-search-bg: #ffffff;
--griddy-search-border: #dee2e6;
font-family: var(--griddy-font-family);
font-size: var(--griddy-font-size);
position: relative;
width: 100%;
border: 1px solid var(--griddy-border-color);
border-radius: 4px;
overflow: hidden;
}
/* ─── Container (scroll area) ──────────────────────────────────────────── */
.griddy-container {
outline: none;
}
.griddy-container:focus-visible {
box-shadow: inset 0 0 0 2px var(--griddy-focus-color);
}
/* ─── Header ───────────────────────────────────────────────────────────── */
.griddy-thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--griddy-header-bg);
border-bottom: 2px solid var(--griddy-border-color);
}
.griddy-header-row {
display: flex;
width: 100%;
}
.griddy-header-cell {
display: flex;
align-items: center;
padding: var(--griddy-cell-padding);
height: 36px;
font-weight: 600;
color: var(--griddy-header-color);
border-right: 1px solid var(--griddy-border-color);
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
flex-shrink: 0;
}
.griddy-header-cell:last-child {
border-right: none;
flex: 1;
}
.griddy-header-cell--sortable {
cursor: pointer;
}
.griddy-header-cell--sortable:hover {
background: rgba(0, 0, 0, 0.04);
}
.griddy-header-cell--sorted {
color: var(--griddy-focus-color);
}
/* ─── Sort Indicator ───────────────────────────────────────────────────── */
.griddy-sort-indicator {
margin-left: 4px;
font-size: 12px;
}
/* ─── Header Cell Content ──────────────────────────────────────────────── */
.griddy-header-cell-content {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ─── Filter Button ────────────────────────────────────────────────────── */
.griddy-filter-button {
flex-shrink: 0;
opacity: 0.65;
transition: opacity 0.2s ease;
}
.griddy-filter-button:hover {
opacity: 0.85;
}
.griddy-filter-button--active {
opacity: 1;
}
/* ─── Resize Handle ────────────────────────────────────────────────────── */
.griddy-resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
background: transparent;
}
.griddy-resize-handle:hover {
background: var(--griddy-focus-color);
}
/* ─── Body ─────────────────────────────────────────────────────────────── */
.griddy-tbody {
width: 100%;
}
/* ─── Row ──────────────────────────────────────────────────────────────── */
.griddy-row {
display: flex;
width: 100%;
border-bottom: 1px solid var(--griddy-border-color);
background: var(--griddy-row-bg);
cursor: default;
box-sizing: border-box;
}
.griddy-row:hover {
background: var(--griddy-row-hover-bg);
}
.griddy-row--even {
background: var(--griddy-row-even-bg);
}
.griddy-row--even:hover {
background: var(--griddy-row-hover-bg);
}
.griddy-row--focused {
outline: 2px solid var(--griddy-focus-color);
outline-offset: -2px;
z-index: 1;
}
.griddy-row--selected {
background-color: var(--griddy-selection-bg);
}
.griddy-row--selected:hover {
background-color: rgba(34, 139, 230, 0.15);
}
.griddy-row--focused.griddy-row--selected {
outline: 2px solid var(--griddy-focus-color);
background-color: var(--griddy-selection-bg);
}
/* ─── Cell ─────────────────────────────────────────────────────────────── */
.griddy-cell {
display: flex;
align-items: center;
padding: var(--griddy-cell-padding);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-right: 1px solid var(--griddy-border-color);
flex-shrink: 0;
}
.griddy-cell:last-child {
border-right: none;
flex: 1;
}
.griddy-cell--editing {
padding: 0;
}
/* ─── Checkbox ─────────────────────────────────────────────────────────── */
.griddy-checkbox {
cursor: pointer;
margin: 0 auto;
display: block;
}
/* ─── Search Overlay ───────────────────────────────────────────────────── */
.griddy-search-overlay {
position: absolute;
top: 0;
right: 0;
z-index: 10;
padding: 8px;
background: var(--griddy-search-bg);
border: 1px solid var(--griddy-search-border);
border-radius: 0 0 0 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.griddy-search-input {
font-size: var(--griddy-font-size);
padding: 4px 8px;
border: 1px solid var(--griddy-search-border);
border-radius: 4px;
outline: none;
width: 240px;
}
.griddy-search-input:focus {
border-color: var(--griddy-focus-color);
box-shadow: 0 0 0 2px rgba(34, 139, 230, 0.2);
}
/* ─── Pagination ───────────────────────────────────────────────────────── */
.griddy-pagination {
border-top: 1px solid var(--griddy-border-color);
background: var(--griddy-header-bg);
}
/* ─── Infinite Scroll Loading ───────────────────────────────────────────── */
.griddy-loading-indicator {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: var(--griddy-row-bg);
border-top: 1px solid var(--griddy-border-color);
}
.griddy-loading-spinner {
color: var(--griddy-focus-color);
font-size: var(--griddy-font-size);
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.griddy-loading-spinner::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--griddy-focus-color);
border-right-color: transparent;
border-radius: 50%;
animation: griddy-spin 0.6s linear infinite;
}
@keyframes griddy-spin {
to {
transform: rotate(360deg);
}
}
/* ─── Column Pinning ─────────────────────────────────────────────────────── */
.griddy-header-cell--pinned-left,
.griddy-cell--pinned-left {
background: var(--griddy-header-bg);
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
}
.griddy-header-cell--pinned-right,
.griddy-cell--pinned-right {
background: var(--griddy-header-bg);
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.1);
}
.griddy-cell--pinned-left,
.griddy-cell--pinned-right {
background: var(--griddy-row-bg);
}
.griddy-row:hover .griddy-cell--pinned-left,
.griddy-row:hover .griddy-cell--pinned-right {
background: var(--griddy-row-hover-bg);
}
.griddy-row--selected .griddy-cell--pinned-left,
.griddy-row--selected .griddy-cell--pinned-right {
background: var(--griddy-selection-bg);
}
/* ─── Data Grouping ──────────────────────────────────────────────────────── */
.griddy-row--grouped {
background: var(--griddy-header-bg);
font-weight: 600;
}
/* ─── Column Reordering ──────────────────────────────────────────────────── */
.griddy-header-cell--dragging {
opacity: 0.5;
cursor: grabbing !important;
}
.griddy-header-cell[draggable="true"] {
cursor: grab;
}
.griddy-header-cell[draggable="true"]:active {
cursor: grabbing;
}
/* ─── Error Boundary ──────────────────────────────────────────────────── */
.griddy-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
gap: 12px;
min-height: 200px;
background: #fff5f5;
border: 1px solid #ffc9c9;
border-radius: 4px;
}
.griddy-error-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #ff6b6b;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
}
.griddy-error-message {
font-size: 16px;
font-weight: 600;
color: #c92a2a;
}
.griddy-error-detail {
font-size: 13px;
color: #868e96;
max-width: 400px;
text-align: center;
word-break: break-word;
}
.griddy-error-retry {
margin-top: 4px;
padding: 6px 16px;
font-size: 14px;
font-weight: 500;
color: #fff;
background: var(--griddy-focus-color, #228be6);
border: none;
border-radius: 4px;
cursor: pointer;
}
.griddy-error-retry:hover {
opacity: 0.9;
}
/* ─── Loading Skeleton ────────────────────────────────────────────────── */
.griddy-skeleton {
width: 100%;
}
.griddy-skeleton-row {
display: flex;
width: 100%;
border-bottom: 1px solid var(--griddy-border-color);
align-items: center;
}
.griddy-skeleton-cell {
padding: var(--griddy-cell-padding);
flex-shrink: 0;
overflow: hidden;
}
.griddy-skeleton-bar {
height: 14px;
width: 70%;
background: linear-gradient(90deg, #e9ecef 25%, #f1f3f5 50%, #e9ecef 75%);
background-size: 200% 100%;
animation: griddy-shimmer 1.5s ease-in-out infinite;
border-radius: 3px;
}
@keyframes griddy-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ─── Loading Overlay ─────────────────────────────────────────────────── */
.griddy-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
/* ─── Renderers ───────────────────────────────────────────────────────── */
.griddy-renderer-progress {
width: 100%;
height: 16px;
background: #e9ecef;
border-radius: 8px;
position: relative;
overflow: hidden;
}
.griddy-renderer-progress-bar {
height: 100%;
border-radius: 8px;
transition: width 0.3s ease;
}
.griddy-renderer-progress-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: #212529;
}
.griddy-renderer-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
color: #fff;
white-space: nowrap;
}
.griddy-renderer-image {
display: block;
border-radius: 4px;
object-fit: cover;
}
.griddy-renderer-sparkline {
display: block;
}
/* ─── Quick Filter ────────────────────────────────────────────────────── */
.griddy-quick-filter {
border-top: 1px solid var(--griddy-border-color);
padding-top: 8px;
margin-top: 4px;
}
/* ─── Advanced Search ─────────────────────────────────────────────────── */
.griddy-advanced-search {
padding: 8px 12px;
background: var(--griddy-header-bg);
border-bottom: 1px solid var(--griddy-border-color);
}
/* ─── Search History ──────────────────────────────────────────────────── */
.griddy-search-history {
margin-top: 4px;
padding: 4px 0;
border-top: 1px solid var(--griddy-border-color);
}
.griddy-search-history-item {
display: block;
width: 100%;
padding: 4px 8px;
font-size: 13px;
text-align: left;
background: none;
border: none;
cursor: pointer;
border-radius: 3px;
color: inherit;
}
.griddy-search-history-item:hover {
background: var(--griddy-row-hover-bg);
}
/* ─── Tree/Hierarchical Data ──────────────────────────────────────────────── */
.griddy-tree-expand-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
margin-right: 4px;
background: none;
border: none;
cursor: pointer;
color: var(--mantine-color-gray-6, #868e96);
font-size: 10px;
flex-shrink: 0;
transition: color 0.15s ease;
}
.griddy-tree-expand-button:hover:not(:disabled) {
color: var(--mantine-color-gray-9, #212529);
}
.griddy-tree-expand-button:disabled {
cursor: wait;
opacity: 0.5;
}
.griddy-tree-expand-button:focus-visible {
outline: 2px solid var(--griddy-focus-color);
outline-offset: 2px;
border-radius: 2px;
}
/* Optional: Depth visual indicators */
.griddy-row--tree-depth-1 .griddy-cell:first-child {
border-left: 2px solid var(--mantine-color-gray-3, #dee2e6);
}
.griddy-row--tree-depth-2 .griddy-cell:first-child {
border-left: 2px solid var(--mantine-color-blue-3, #74c0fc);
}
.griddy-row--tree-depth-3 .griddy-cell:first-child {
border-left: 2px solid var(--mantine-color-teal-3, #63e6be);
}
.griddy-row--tree-depth-4 .griddy-cell:first-child {
border-left: 2px solid var(--mantine-color-grape-3, #da77f2);
}
/* ─── Dark Mode ──────────────────────────────────────────────────────── */
:global([data-mantine-color-scheme="dark"]) .griddy {
--griddy-border-color: #373a40;
--griddy-header-bg: #25262b;
--griddy-header-color: #c1c2c5;
--griddy-row-bg: #1a1b1e;
--griddy-row-hover-bg: #25262b;
--griddy-row-even-bg: #1a1b1e;
--griddy-focus-color: #339af0;
--griddy-selection-bg: rgba(51, 154, 240, 0.15);
--griddy-search-bg: #25262b;
--griddy-search-border: #373a40;
}
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--sortable:hover {
background: rgba(255, 255, 255, 0.04);
}
:global([data-mantine-color-scheme="dark"]) .griddy-row--selected:hover {
background-color: rgba(51, 154, 240, 0.2);
}
:global([data-mantine-color-scheme="dark"]) .griddy-search-overlay {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
:global([data-mantine-color-scheme="dark"]) .griddy-search-input:focus {
box-shadow: 0 0 0 2px rgba(51, 154, 240, 0.25);
}
:global([data-mantine-color-scheme="dark"]) .griddy-error {
background: #2c2e33;
border-color: #e03131;
}
:global([data-mantine-color-scheme="dark"]) .griddy-error-message {
color: #ff6b6b;
}
:global([data-mantine-color-scheme="dark"]) .griddy-error-detail {
color: #909296;
}
:global([data-mantine-color-scheme="dark"]) .griddy-skeleton-bar {
background: linear-gradient(90deg, #373a40 25%, #2c2e33 50%, #373a40 75%);
background-size: 200% 100%;
}
:global([data-mantine-color-scheme="dark"]) .griddy-loading-overlay {
background: rgba(0, 0, 0, 0.4);
}
:global([data-mantine-color-scheme="dark"]) .griddy-renderer-progress {
background: #373a40;
}
:global([data-mantine-color-scheme="dark"]) .griddy-renderer-progress-label {
color: #c1c2c5;
}
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--pinned-left,
:global([data-mantine-color-scheme="dark"]) .griddy-cell--pinned-left {
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.3);
}
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--pinned-right,
:global([data-mantine-color-scheme="dark"]) .griddy-cell--pinned-right {
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.3);
}

View File

@@ -7,7 +7,7 @@ import {
} from '@glideapps/glide-data-grid';
import { Group, Stack } from '@mantine/core';
import { useElementSize, useMergedRef } from '@mantine/hooks';
import React from 'react';
import React, { useEffect } from 'react';
import { BottomBar } from './components/BottomBar';
import { Computer } from './components/Computer';
@@ -107,14 +107,22 @@ export const GridlerDataGrid = () => {
setStateFN('_glideref', () => {
return r ?? undefined;
});
const ready = getState('ready');
const newReady = !!(r && mounted);
if (ready !== newReady) {
setState('ready', newReady);
}
});
useEffect(() => {
if (ref.current && mounted) {
const currentReady = getState('ready');
if (!currentReady) {
setState('ready', true);
}
} else {
const currentReady = getState('ready');
if (currentReady) {
setState('ready', false);
}
}
}, [mounted, getState, setState]);
const theme = useGridTheme();
return (
@@ -158,7 +166,7 @@ export const GridlerDataGrid = () => {
columns={(renderColumns as Array<GridColumn>) ?? []}
columnSelect="none"
drawFocusRing
height={height ?? 400}
height={height || 400}
overscrollX={16}
overscrollY={32}
rangeSelect={allowMultiSelect ? 'multi-rect' : 'cell'}
@@ -188,6 +196,7 @@ export const GridlerDataGrid = () => {
if (!refContextActivated.current) {
refContextActivated.current = true;
onContextClick('cell', event, cell[0], cell[1]);
setTimeout(() => {
refContextActivated.current = false;
}, 100);
@@ -231,7 +240,7 @@ export const GridlerDataGrid = () => {
rows = rows.hasIndex(r) ? rows : rows.add(r);
}
}
console.log('Debug:onGridSelectionChange', currentSelection, selection);
//console.log('Debug:onGridSelectionChange', currentSelection, selection);
if (
JSON.stringify(currentSelection?.columns) !== JSON.stringify(selection.columns) ||
JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows) ||
@@ -275,7 +284,7 @@ export const GridlerDataGrid = () => {
rows={total_rows ?? 0}
theme={theme.gridTheme}
width={width ?? 200}
width={width || 200}
/>
)}

View File

@@ -28,7 +28,7 @@ export const Computer = React.memo(() => {
selectFirstRowOnMount,
setState,
setStateFN,
values
values,
} = useGridlerStore((s) => ({
_glideref: s._glideref,
_gridSelectionRows: s._gridSelectionRows,
@@ -45,7 +45,7 @@ export const Computer = React.memo(() => {
scrollToRowKey: s.scrollToRowKey,
searchStr: s.searchStr,
selectedRowKey: s.selectedRowKey,
selectFirstRowOnMount:s.selectFirstRowOnMount,
selectFirstRowOnMount: s.selectFirstRowOnMount,
setState: s.setState,
setStateFN: s.setStateFN,
uniqueid: s.uniqueid,
@@ -71,13 +71,10 @@ export const Computer = React.memo(() => {
//When values change, update selection
useEffect(() => {
const searchSelection = async () => {
const page_data = getState('_page_data');
const pageSize = getState('pageSize');
const searchSelection = async (values: Array<Record<string, unknown>>) => {
const keyField = getState('keyField') ?? 'id';
const rowIndexes = [];
for (const vi in values as Array<Record<string, unknown>>) {
let rowIndex = -1;
const key = String(
typeof values?.[vi] === 'object'
? values?.[vi]?.[keyField]
@@ -85,26 +82,12 @@ export const Computer = React.memo(() => {
? values?.[vi]
: undefined
);
for (const p in page_data) {
for (const r in page_data[p]) {
const idx = Number(p) * pageSize + Number(r);
if (String(page_data[p][r]?.[keyField]) === key) {
//console.log('Found row S', idx, page_data[p][r], page_data[p][r]?.[keyField], key);
rowIndex = idx;
break;
}
}
if (rowIndex >= 0) {
rowIndexes.push(rowIndex);
break;
}
if (!key) {
continue;
}
if (!(rowIndex >= 0)) {
const idx = await getRowIndexByKey(key);
if (idx) {
rowIndexes.push(idx);
}
const idx = await getRowIndexByKey(key);
if (idx !== null && idx !== undefined) {
rowIndexes.push(idx);
}
}
@@ -112,10 +95,12 @@ export const Computer = React.memo(() => {
};
if (values) {
searchSelection().then((rowIndexes) => {
searchSelection(values).then((rowIndexes) => {
let rows = CompactSelection.empty();
rowIndexes.forEach((r) => {
rows = rows.add(r);
if (r !== undefined) {
rows = rows.add(r);
}
});
setStateFN('_gridSelectionRows', () => {
@@ -259,6 +244,7 @@ export const Computer = React.memo(() => {
return;
}
if (refFirstRun.current > 0) {
getState('refreshCells')?.();
return;
}
refFirstRun.current = 1;
@@ -276,10 +262,7 @@ export const Computer = React.memo(() => {
const ready = getState('ready');
if (ready && selectFirstRowOnMount) {
const scrollToRowKey = getState('scrollToRowKey');
if (scrollToRowKey && scrollToRowKey >= 0) {
return;
}
@@ -291,21 +274,18 @@ export const Computer = React.memo(() => {
const firstRow = firstBuffer?.[keyField] ?? -1;
const currentValues = getState('values') ?? [];
if (
!(values && values.length > 0) &&
firstRow &&
firstRow > 0 &&
(currentValues.length ?? 0) === 0
) {
const values = [firstBuffer, ...(currentValues as Array<Record<string, unknown>>)];
if (!firstBuffer) {
return;
}
if (firstRow && firstRow > 0 && (currentValues.length ?? 0) === 0) {
const newValues = [firstBuffer];
const onChange = getState('onChange');
//console.log('Selecting first row:', firstRow, firstBuffer, values);
if (onChange) {
onChange(values);
onChange(newValues);
} else {
setState('values', values);
setState('values', newValues);
}
setState('scrollToRowKey', firstRow);
@@ -318,7 +298,7 @@ export const Computer = React.memo(() => {
return () => {
_events?.removeEventListener('loadPage', loadPage);
};
}, [ready, selectFirstRowOnMount]);
}, [ready, selectFirstRowOnMount, values]);
/// logic to apply the selected row.
// useEffect(() => {
@@ -348,10 +328,9 @@ export const Computer = React.memo(() => {
const key = selectedRowKey ?? scrollToRowKey;
if (key && ref && ready) {
//console.log('Computer:Scrolling to key:', key);
getRowIndexByKey?.(key).then((r) => {
if (r !== undefined) {
//console.log('Scrolling to selected row:', r, selectedRowKey, scrollToRowKey);
if (selectedRowKey) {
const onChange = getState('onChange');
const selected = [{ [getState('keyField') ?? 'id']: selectedRowKey }];
@@ -379,15 +358,6 @@ export const Computer = React.memo(() => {
}
}, [scrollToRowKey, selectedRowKey]);
// console.log('Gridler:Debug:Computer', {
// colFilters,
// colOrder,
// colSize,
// colSort,
// columns,
// uniqueid
// });
return <></>;
});

View File

@@ -140,7 +140,7 @@ export interface GridlerState {
_visibleArea: Rectangle;
_visiblePages: Rectangle;
addError: (err: string, ...args: Array<any>) => void;
askAPIRowNumber?: (key: string) => Promise<number>;
askAPIRowNumber?: (key: string) => Promise<null | number>;
colFilters?: Array<FilterOption>;
colOrder?: Record<string, number>;
colSize?: Record<string, number>;
@@ -162,7 +162,7 @@ export interface GridlerState {
hasLocalData: boolean;
isEmpty: boolean;
isValuesInPages: () => boolean
isValuesInPages: () => boolean;
loadingData?: boolean;
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
mounted: boolean;
@@ -191,7 +191,6 @@ export interface GridlerState {
freezeRegions?: readonly Rectangle[];
selected?: Item;
}
) => void;
pageSize: number;
@@ -200,10 +199,7 @@ export interface GridlerState {
reload?: () => Promise<void>;
renderColumns?: GridlerColumns;
setState: <K extends keyof GridlerStoreState>(
key: K,
value: GridlerStoreState[K]
) => void;
setState: <K extends keyof GridlerStoreState>(key: K, value: GridlerStoreState[K]) => void;
setStateFN: <K extends keyof GridlerStoreState>(
key: K,
value: (current: GridlerStoreState[K]) => Partial<GridlerStoreState[K]>
@@ -340,7 +336,9 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
},
getRowIndexByKey: async (key: number | string) => {
const state = get();
if (key === undefined || key === null) {
return undefined;
}
let rowIndex = -1;
if (state.ready) {
const page_data = state._page_data;
@@ -352,22 +350,22 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
//console.log('Found row', idx, page_data[p][r]?.[keyField], scrollToRowKey);
if (String(page_data[p][r]?.[keyField]) === String(key)) {
rowIndex =
page_data[p][r]?._rownumber > 0 ? page_data[p][r]?._rownumber : idx > 0 ? idx : -1;
page_data[p][r]?._rownumber > 0 ? page_data[p][r]?._rownumber : idx >= 0 ? idx : -1;
break;
}
}
if (rowIndex > 0) {
console.log('Local row index', rowIndex, key);
if (rowIndex >= 0) {
//console.log('Local row index', rowIndex, key);
return rowIndex;
}
}
if (rowIndex > 0) {
if (rowIndex >= 0) {
return rowIndex;
} else if (typeof state.askAPIRowNumber === 'function') {
const rn = await state.askAPIRowNumber(String(key));
if (rn && rn >= 0) {
console.log('Remote row index', rowIndex, key);
return rn;
}
}
@@ -403,7 +401,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
}
}
return false
return false;
},
keyField: 'id',
loadPage: async (pPage: number, clearMode?: 'all' | 'page') => {
@@ -461,6 +459,8 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
detail: { clearMode, data, page: pPage, state },
})
);
state.refreshCells();
})
.catch((e) => {
console.error('loadPage Error: ', page, e);
@@ -488,13 +488,16 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
onCellClicked: (cell: Item, event: CellClickedEventArgs) => {
const state = get();
const [col, row] = cell;
const rowBuffer = state.getRowBuffer(row);
if (state.glideProps?.onCellClicked) {
state.glideProps?.onCellClicked?.(cell, event);
}
if (state.values?.length) {
if (state.values?.length && state.values?.length > 0) {
if (state.onChange) {
state.onChange(state.values);
}
} else if (rowBuffer && state.onChange) {
state.onChange([rowBuffer]);
}
state._events.dispatchEvent(
@@ -949,7 +952,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
}
},
total_rows: 1000,
uniqueid: getUUID()
uniqueid: getUUID(),
}),
(props) => {
const [setState, getState] = props.useStore((s) => [s.setState, s.getState]);

View File

@@ -36,7 +36,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
const searchStr = getState('searchStr');
const searchFields = getState('searchFields');
const _active_requests = getState('_active_requests');
const keyField = getState('keyField');
const keyField = getState('keyField');
setState('loadingData', true);
try {
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
@@ -83,7 +83,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
)
?.forEach((filter: any) => {
ops.push({
name: `${filter.id ?? ""}`,
name: `${filter.id ?? ''}`,
op: 'contains',
type: 'searchor',
value: searchStr,
@@ -202,11 +202,13 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
]
);
const askAPIRowNumber: (key: string) => Promise<number> = useCallback(
const askAPIRowNumber: (key: string) => Promise<null | number> = useCallback(
async (key: string) => {
const colFilters = getState('colFilters');
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
if (!key || key === '' || !props.url) {
return null;
}
//console.log('APIAdaptorGoLangv2', { key, props });
if (props && props.url) {
const head = new Headers();
const ops: FetchAPIOperation[] = [
@@ -250,7 +252,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
const controller = new AbortController();
const res = await fetch(`${props.url}?x-fetch-rownumber=${key}}`, {
const res = await fetch(`${props.url}?x-fetch-rownumber=${key}`, {
headers: head,
method: 'GET',
signal: controller?.signal,
@@ -265,7 +267,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
}
return [];
},
[props.url, props.authtoken, props.filter, props.options, getState, addError]
[props.url, props.authtoken, props.filter, JSON.stringify(props.options), getState, addError]
);
//Reset the function in the store.
@@ -276,7 +278,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
const _refresh = getState('_refresh');
if (!isValuesInPages) {
setState('values', []);
setState('values', []);
}
//Reset the loaded pages to new rules
@@ -288,13 +290,13 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
onChange(buffers);
}
});
getState('refreshCells')?.();
}, [props.url, props.authtoken, props.filter, JSON.stringify(props.options), mounted, setState]);
return <></>;
}
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
export const GlidlerAPIAdaptorForGoLangv2 = React.memo(_GlidlerAPIAdaptorForGoLangv2);

View File

@@ -61,7 +61,7 @@ export function GlidlerFormAdaptor(props: {
col?: GridlerColumn,
defaultItems?: MantineBetterMenuInstanceItem[]
): MantineBetterMenuInstanceItem[] => {
//console.log('GlidlerFormInterface getMenuItems', id);
//console.log('GlidlerFormInterface getMenuItems', id, row, defaultItems);
if (id === 'header-menu') {
return defaultItems || [];
@@ -88,7 +88,7 @@ export function GlidlerFormAdaptor(props: {
? props.descriptionField(row)
: undefined;
if (id === 'other') {
if (id === 'other' || (id === 'cell' && !row)) {
items.push({
c: 'blue',
label: 'Add',

Some files were not shown because too many files have changed in this diff Show More