Compare commits

7 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
70 changed files with 8416 additions and 2340 deletions

2
.gitignore vendored
View File

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

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,7 +30,7 @@ const preview: Preview = {
},
},
layout: 'fullscreen',
viewMode: 'responsive',
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,7 @@ export const PreviewDecorator: Decorator = (Story, context) => {
};
return (
<MantineProvider>
<MantineProvider forceColorScheme={colorScheme}>
<ModalsProvider>
{useGlobalStore ? (

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

@@ -110,6 +110,7 @@
"@tanstack/react-table": "^8.21.3",
"@warkypublic/artemis-kit": "^1.0.10",
"@warkypublic/zustandsyncstore": "^1.0.0",
"@warkypublic/resolvespec-js": "^1.0.1",
"idb-keyval": "^6.2.2",
"immer": "^10.1.3",
"react": ">= 19.0.0",

View File

@@ -1,500 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner "Storybook" [ref=e6]:
- heading "Storybook" [level=1] [ref=e7]
- img
- generic [ref=e11]:
- generic [ref=e12]:
- generic [ref=e13]:
- link "Skip to content" [ref=e14] [cursor=pointer]:
- /url: "#storybook-preview-wrapper"
- link "Storybook" [ref=e16] [cursor=pointer]:
- /url: ./
- img "Storybook" [ref=e17]
- switch "Settings" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- generic [ref=e28]:
- generic [ref=e30] [cursor=pointer]:
- button "Open onboarding guide" [ref=e34]:
- img [ref=e36]
- strong [ref=e38]: Get started
- generic [ref=e39]:
- button "Collapse onboarding checklist" [expanded] [ref=e40]:
- img [ref=e41]
- button "25% completed" [ref=e43]:
- generic [ref=e44]:
- img [ref=e45]
- img [ref=e47]
- generic [ref=e50]: 25%
- list [ref=e52]:
- listitem [ref=e53]:
- button "Open onboarding guide for See what's new" [ref=e54] [cursor=pointer]:
- img [ref=e56]
- generic [ref=e59]: See what's new
- button "Go"
- listitem [ref=e60]:
- button "Open onboarding guide for Change a story with Controls" [ref=e61] [cursor=pointer]:
- img [ref=e63]
- generic [ref=e66]: Change a story with Controls
- listitem [ref=e67]:
- button "Open onboarding guide for Install Vitest addon" [ref=e68] [cursor=pointer]:
- img [ref=e70]
- generic [ref=e73]: Install Vitest addon
- generic [ref=e74]: Search for components
- search [ref=e75]:
- combobox "Search for components" [ref=e76]:
- generic:
- img
- searchbox "Search for components" [ref=e77]
- code: ⌃ K
- button "Tag filters" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- button "Create a new story" [ref=e82] [cursor=pointer]:
- img [ref=e83]
- navigation "Stories" [ref=e86]:
- heading "Stories" [level=2] [ref=e87]
- generic [ref=e89]:
- generic [ref=e90]:
- button "Collapse" [expanded] [ref=e91] [cursor=pointer]:
- img [ref=e93]
- text: Components
- button "Expand all" [ref=e95] [cursor=pointer]:
- img [ref=e96]
- button "Boxer" [ref=e99] [cursor=pointer]:
- generic [ref=e100]:
- img [ref=e102]
- img [ref=e104]
- text: Boxer
- button "Griddy" [expanded] [ref=e107] [cursor=pointer]:
- generic [ref=e108]:
- img [ref=e110]
- img [ref=e112]
- text: Griddy
- link "Basic" [ref=e115] [cursor=pointer]:
- /url: /?path=/story/components-griddy--basic
- img [ref=e117]
- text: Basic
- link "Large Dataset" [ref=e120] [cursor=pointer]:
- /url: /?path=/story/components-griddy--large-dataset
- img [ref=e122]
- text: Large Dataset
- link "Single Selection" [ref=e125] [cursor=pointer]:
- /url: /?path=/story/components-griddy--single-selection
- img [ref=e127]
- text: Single Selection
- link "Multi Selection" [ref=e130] [cursor=pointer]:
- /url: /?path=/story/components-griddy--multi-selection
- img [ref=e132]
- text: Multi Selection
- link "Large Multi Selection" [ref=e135] [cursor=pointer]:
- /url: /?path=/story/components-griddy--large-multi-selection
- img [ref=e137]
- text: Large Multi Selection
- link "With Search" [ref=e140] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-search
- img [ref=e142]
- text: With Search
- link "Keyboard Navigation" [ref=e145] [cursor=pointer]:
- /url: /?path=/story/components-griddy--keyboard-navigation
- img [ref=e147]
- text: Keyboard Navigation
- generic [ref=e149]:
- link "With Text Filtering" [ref=e150] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-text-filtering
- img [ref=e152]
- text: With Text Filtering
- link "Skip to content" [ref=e154] [cursor=pointer]:
- /url: "#storybook-preview-wrapper"
- link "With Number Filtering" [ref=e156] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-number-filtering
- img [ref=e158]
- text: With Number Filtering
- link "With Enum Filtering" [ref=e161] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-enum-filtering
- img [ref=e163]
- text: With Enum Filtering
- link "With Boolean Filtering" [ref=e166] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-boolean-filtering
- img [ref=e168]
- text: With Boolean Filtering
- link "With All Filter Types" [ref=e171] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-all-filter-types
- img [ref=e173]
- text: With All Filter Types
- link "Large Dataset With Filtering" [ref=e176] [cursor=pointer]:
- /url: /?path=/story/components-griddy--large-dataset-with-filtering
- img [ref=e178]
- text: Large Dataset With Filtering
- generic [ref=e180]:
- button "Collapse" [expanded] [ref=e181] [cursor=pointer]:
- img [ref=e183]
- text: Former
- button "Expand all" [ref=e185] [cursor=pointer]:
- img [ref=e186]
- button "Former Basic" [ref=e189] [cursor=pointer]:
- generic [ref=e190]:
- img [ref=e192]
- img [ref=e194]
- text: Former Basic
- button "Controls Basic" [ref=e197] [cursor=pointer]:
- generic [ref=e198]:
- img [ref=e200]
- img [ref=e202]
- text: Controls Basic
- generic [ref=e204]:
- button "Collapse" [expanded] [ref=e205] [cursor=pointer]:
- img [ref=e207]
- text: State
- button "Expand all" [ref=e209] [cursor=pointer]:
- img [ref=e210]
- button "GlobalStateStore" [ref=e213] [cursor=pointer]:
- generic [ref=e214]:
- img [ref=e216]
- img [ref=e218]
- text: GlobalStateStore
- generic [ref=e220]:
- button "Collapse" [expanded] [ref=e221] [cursor=pointer]:
- img [ref=e223]
- text: Grid
- button "Expand all" [ref=e225] [cursor=pointer]:
- img [ref=e226]
- button "Gridler API" [ref=e229] [cursor=pointer]:
- generic [ref=e230]:
- img [ref=e232]
- img [ref=e234]
- text: Gridler API
- button "Gridler Local" [ref=e237] [cursor=pointer]:
- generic [ref=e238]:
- img [ref=e240]
- img [ref=e242]
- text: Gridler Local
- generic [ref=e244]:
- button "Collapse" [expanded] [ref=e245] [cursor=pointer]:
- img [ref=e247]
- text: UI
- button "Expand all" [ref=e249] [cursor=pointer]:
- img [ref=e250]
- button "Mantine Better Menu" [ref=e253] [cursor=pointer]:
- generic [ref=e254]:
- img [ref=e256]
- img [ref=e258]
- text: Mantine Better Menu
- generic [ref=e261]:
- region "Toolbar" [ref=e262]:
- heading "Toolbar" [level=2] [ref=e263]
- toolbar [ref=e264]:
- generic [ref=e265]:
- button "Reload story" [ref=e266] [cursor=pointer]:
- img [ref=e267]
- switch "Grid visibility" [ref=e269] [cursor=pointer]:
- img [ref=e270]
- button "Preview background" [ref=e272] [cursor=pointer]:
- img [ref=e273]
- switch "Measure tool" [ref=e276] [cursor=pointer]:
- img [ref=e277]
- switch "Outline tool" [ref=e280] [cursor=pointer]:
- img [ref=e281]
- button "Viewport size" [ref=e283] [cursor=pointer]:
- img [ref=e284]
- generic [ref=e288]:
- switch "Change zoom level" [ref=e289] [cursor=pointer]: 100%
- button "Enter full screen" [ref=e290] [cursor=pointer]:
- img [ref=e291]
- button "Share" [ref=e293] [cursor=pointer]:
- img [ref=e294]
- button "Open in editor" [ref=e297] [cursor=pointer]:
- img [ref=e298]
- main "Main preview area" [ref=e301]:
- heading "Main preview area" [level=2] [ref=e302]
- generic [ref=e304]:
- link "Skip to sidebar" [ref=e305] [cursor=pointer]:
- /url: "#components-griddy--with-text-filtering"
- iframe [ref=e309]:
- generic [ref=f1e4]:
- grid "Data grid" [ref=f1e5]:
- generic [ref=f1e6]:
- rowgroup [ref=f1e7]:
- row "ID First Name Filter status indicator Last Name Filter status indicator Email Age Department Salary Start Date Active" [ref=f1e8]:
- columnheader "ID" [ref=f1e9] [cursor=pointer]:
- generic [ref=f1e11]: ID
- columnheader "First Name Filter status indicator" [ref=f1e13] [cursor=pointer]:
- generic [ref=f1e15]:
- text: First Name
- button "Filter status indicator" [disabled] [ref=f1e16]:
- img [ref=f1e18]
- columnheader "Last Name Filter status indicator" [ref=f1e21] [cursor=pointer]:
- generic [ref=f1e23]:
- text: Last Name
- button "Filter status indicator" [disabled] [ref=f1e24]:
- img [ref=f1e26]
- columnheader "Email" [ref=f1e29] [cursor=pointer]:
- generic [ref=f1e31]: Email
- columnheader "Age" [ref=f1e33] [cursor=pointer]:
- generic [ref=f1e35]: Age
- columnheader "Department" [ref=f1e37] [cursor=pointer]:
- generic [ref=f1e39]: Department
- columnheader "Salary" [ref=f1e41] [cursor=pointer]:
- generic [ref=f1e43]: Salary
- columnheader "Start Date" [ref=f1e45] [cursor=pointer]:
- generic [ref=f1e47]: Start Date
- columnheader "Active" [ref=f1e49] [cursor=pointer]:
- generic [ref=f1e51]: Active
- rowgroup [ref=f1e53]:
- row "1 Alice Smith alice.smith@example.com 22 Engineering $40,000 2020-01-01 No" [ref=f1e54]:
- gridcell "1" [ref=f1e55]
- gridcell "Alice" [ref=f1e56]
- gridcell "Smith" [ref=f1e57]
- gridcell "alice.smith@example.com" [ref=f1e58]
- gridcell "22" [ref=f1e59]
- gridcell "Engineering" [ref=f1e60]
- gridcell "$40,000" [ref=f1e61]
- gridcell "2020-01-01" [ref=f1e62]
- gridcell "No" [ref=f1e63]
- row "2 Bob Johnson bob.johnson@example.com 23 Marketing $41,234 2021-02-02 Yes" [ref=f1e64]:
- gridcell "2" [ref=f1e65]
- gridcell "Bob" [ref=f1e66]
- gridcell "Johnson" [ref=f1e67]
- gridcell "bob.johnson@example.com" [ref=f1e68]
- gridcell "23" [ref=f1e69]
- gridcell "Marketing" [ref=f1e70]
- gridcell "$41,234" [ref=f1e71]
- gridcell "2021-02-02" [ref=f1e72]
- gridcell "Yes" [ref=f1e73]
- row "3 Charlie Williams charlie.williams@example.com 24 Sales $42,468 2022-03-03 Yes" [ref=f1e74]:
- gridcell "3" [ref=f1e75]
- gridcell "Charlie" [ref=f1e76]
- gridcell "Williams" [ref=f1e77]
- gridcell "charlie.williams@example.com" [ref=f1e78]
- gridcell "24" [ref=f1e79]
- gridcell "Sales" [ref=f1e80]
- gridcell "$42,468" [ref=f1e81]
- gridcell "2022-03-03" [ref=f1e82]
- gridcell "Yes" [ref=f1e83]
- row "4 Diana Brown diana.brown@example.com 25 HR $43,702 2023-04-04 No" [ref=f1e84]:
- gridcell "4" [ref=f1e85]
- gridcell "Diana" [ref=f1e86]
- gridcell "Brown" [ref=f1e87]
- gridcell "diana.brown@example.com" [ref=f1e88]
- gridcell "25" [ref=f1e89]
- gridcell "HR" [ref=f1e90]
- gridcell "$43,702" [ref=f1e91]
- gridcell "2023-04-04" [ref=f1e92]
- gridcell "No" [ref=f1e93]
- row "5 Eve Jones eve.jones@example.com 26 Finance $44,936 2024-05-05 Yes" [ref=f1e94]:
- gridcell "5" [ref=f1e95]
- gridcell "Eve" [ref=f1e96]
- gridcell "Jones" [ref=f1e97]
- gridcell "eve.jones@example.com" [ref=f1e98]
- gridcell "26" [ref=f1e99]
- gridcell "Finance" [ref=f1e100]
- gridcell "$44,936" [ref=f1e101]
- gridcell "2024-05-05" [ref=f1e102]
- gridcell "Yes" [ref=f1e103]
- row "6 Frank Garcia frank.garcia@example.com 27 Design $46,170 2020-06-06 Yes" [ref=f1e104]:
- gridcell "6" [ref=f1e105]
- gridcell "Frank" [ref=f1e106]
- gridcell "Garcia" [ref=f1e107]
- gridcell "frank.garcia@example.com" [ref=f1e108]
- gridcell "27" [ref=f1e109]
- gridcell "Design" [ref=f1e110]
- gridcell "$46,170" [ref=f1e111]
- gridcell "2020-06-06" [ref=f1e112]
- gridcell "Yes" [ref=f1e113]
- row "7 Grace Miller grace.miller@example.com 28 Legal $47,404 2021-07-07 No" [ref=f1e114]:
- gridcell "7" [ref=f1e115]
- gridcell "Grace" [ref=f1e116]
- gridcell "Miller" [ref=f1e117]
- gridcell "grace.miller@example.com" [ref=f1e118]
- gridcell "28" [ref=f1e119]
- gridcell "Legal" [ref=f1e120]
- gridcell "$47,404" [ref=f1e121]
- gridcell "2021-07-07" [ref=f1e122]
- gridcell "No" [ref=f1e123]
- row "8 Henry Davis henry.davis@example.com 29 Support $48,638 2022-08-08 Yes" [ref=f1e124]:
- gridcell "8" [ref=f1e125]
- gridcell "Henry" [ref=f1e126]
- gridcell "Davis" [ref=f1e127]
- gridcell "henry.davis@example.com" [ref=f1e128]
- gridcell "29" [ref=f1e129]
- gridcell "Support" [ref=f1e130]
- gridcell "$48,638" [ref=f1e131]
- gridcell "2022-08-08" [ref=f1e132]
- gridcell "Yes" [ref=f1e133]
- row "9 Ivy Martinez ivy.martinez@example.com 30 Engineering $49,872 2023-09-09 Yes" [ref=f1e134]:
- gridcell "9" [ref=f1e135]
- gridcell "Ivy" [ref=f1e136]
- gridcell "Martinez" [ref=f1e137]
- gridcell "ivy.martinez@example.com" [ref=f1e138]
- gridcell "30" [ref=f1e139]
- gridcell "Engineering" [ref=f1e140]
- gridcell "$49,872" [ref=f1e141]
- gridcell "2023-09-09" [ref=f1e142]
- gridcell "Yes" [ref=f1e143]
- row "10 Jack Anderson jack.anderson@example.com 31 Marketing $51,106 2024-10-10 No" [ref=f1e144]:
- gridcell "10" [ref=f1e145]
- gridcell "Jack" [ref=f1e146]
- gridcell "Anderson" [ref=f1e147]
- gridcell "jack.anderson@example.com" [ref=f1e148]
- gridcell "31" [ref=f1e149]
- gridcell "Marketing" [ref=f1e150]
- gridcell "$51,106" [ref=f1e151]
- gridcell "2024-10-10" [ref=f1e152]
- gridcell "No" [ref=f1e153]
- row "11 Karen Taylor karen.taylor@example.com 32 Sales $52,340 2020-11-11 Yes" [ref=f1e154]:
- gridcell "11" [ref=f1e155]
- gridcell "Karen" [ref=f1e156]
- gridcell "Taylor" [ref=f1e157]
- gridcell "karen.taylor@example.com" [ref=f1e158]
- gridcell "32" [ref=f1e159]
- gridcell "Sales" [ref=f1e160]
- gridcell "$52,340" [ref=f1e161]
- gridcell "2020-11-11" [ref=f1e162]
- gridcell "Yes" [ref=f1e163]
- row "12 Leo Thomas leo.thomas@example.com 33 HR $53,574 2021-12-12 Yes" [ref=f1e164]:
- gridcell "12" [ref=f1e165]
- gridcell "Leo" [ref=f1e166]
- gridcell "Thomas" [ref=f1e167]
- gridcell "leo.thomas@example.com" [ref=f1e168]
- gridcell "33" [ref=f1e169]
- gridcell "HR" [ref=f1e170]
- gridcell "$53,574" [ref=f1e171]
- gridcell "2021-12-12" [ref=f1e172]
- gridcell "Yes" [ref=f1e173]
- row "13 Mia Hernandez mia.hernandez@example.com 34 Finance $54,808 2022-01-13 No" [ref=f1e174]:
- gridcell "13" [ref=f1e175]
- gridcell "Mia" [ref=f1e176]
- gridcell "Hernandez" [ref=f1e177]
- gridcell "mia.hernandez@example.com" [ref=f1e178]
- gridcell "34" [ref=f1e179]
- gridcell "Finance" [ref=f1e180]
- gridcell "$54,808" [ref=f1e181]
- gridcell "2022-01-13" [ref=f1e182]
- gridcell "No" [ref=f1e183]
- row "14 Nick Moore nick.moore@example.com 35 Design $56,042 2023-02-14 Yes" [ref=f1e184]:
- gridcell "14" [ref=f1e185]
- gridcell "Nick" [ref=f1e186]
- gridcell "Moore" [ref=f1e187]
- gridcell "nick.moore@example.com" [ref=f1e188]
- gridcell "35" [ref=f1e189]
- gridcell "Design" [ref=f1e190]
- gridcell "$56,042" [ref=f1e191]
- gridcell "2023-02-14" [ref=f1e192]
- gridcell "Yes" [ref=f1e193]
- row "15 Olivia Martin olivia.martin@example.com 36 Legal $57,276 2024-03-15 Yes" [ref=f1e194]:
- gridcell "15" [ref=f1e195]
- gridcell "Olivia" [ref=f1e196]
- gridcell "Martin" [ref=f1e197]
- gridcell "olivia.martin@example.com" [ref=f1e198]
- gridcell "36" [ref=f1e199]
- gridcell "Legal" [ref=f1e200]
- gridcell "$57,276" [ref=f1e201]
- gridcell "2024-03-15" [ref=f1e202]
- gridcell "Yes" [ref=f1e203]
- row "16 Paul Jackson paul.jackson@example.com 37 Support $58,510 2020-04-16 No" [ref=f1e204]:
- gridcell "16" [ref=f1e205]
- gridcell "Paul" [ref=f1e206]
- gridcell "Jackson" [ref=f1e207]
- gridcell "paul.jackson@example.com" [ref=f1e208]
- gridcell "37" [ref=f1e209]
- gridcell "Support" [ref=f1e210]
- gridcell "$58,510" [ref=f1e211]
- gridcell "2020-04-16" [ref=f1e212]
- gridcell "No" [ref=f1e213]
- row "17 Quinn Thompson quinn.thompson@example.com 38 Engineering $59,744 2021-05-17 Yes" [ref=f1e214]:
- gridcell "17" [ref=f1e215]
- gridcell "Quinn" [ref=f1e216]
- gridcell "Thompson" [ref=f1e217]
- gridcell "quinn.thompson@example.com" [ref=f1e218]
- gridcell "38" [ref=f1e219]
- gridcell "Engineering" [ref=f1e220]
- gridcell "$59,744" [ref=f1e221]
- gridcell "2021-05-17" [ref=f1e222]
- gridcell "Yes" [ref=f1e223]
- row "18 Rose White rose.white@example.com 39 Marketing $60,978 2022-06-18 Yes" [ref=f1e224]:
- gridcell "18" [ref=f1e225]
- gridcell "Rose" [ref=f1e226]
- gridcell "White" [ref=f1e227]
- gridcell "rose.white@example.com" [ref=f1e228]
- gridcell "39" [ref=f1e229]
- gridcell "Marketing" [ref=f1e230]
- gridcell "$60,978" [ref=f1e231]
- gridcell "2022-06-18" [ref=f1e232]
- gridcell "Yes" [ref=f1e233]
- row "19 Sam Lopez sam.lopez@example.com 40 Sales $62,212 2023-07-19 No" [ref=f1e234]:
- gridcell "19" [ref=f1e235]
- gridcell "Sam" [ref=f1e236]
- gridcell "Lopez" [ref=f1e237]
- gridcell "sam.lopez@example.com" [ref=f1e238]
- gridcell "40" [ref=f1e239]
- gridcell "Sales" [ref=f1e240]
- gridcell "$62,212" [ref=f1e241]
- gridcell "2023-07-19" [ref=f1e242]
- gridcell "No" [ref=f1e243]
- row "20 Tina Lee tina.lee@example.com 41 HR $63,446 2024-08-20 Yes" [ref=f1e244]:
- gridcell "20" [ref=f1e245]
- gridcell "Tina" [ref=f1e246]
- gridcell "Lee" [ref=f1e247]
- gridcell "tina.lee@example.com" [ref=f1e248]
- gridcell "41" [ref=f1e249]
- gridcell "HR" [ref=f1e250]
- gridcell "$63,446" [ref=f1e251]
- gridcell "2024-08-20" [ref=f1e252]
- gridcell "Yes" [ref=f1e253]
- generic [ref=f1e254]:
- strong [ref=f1e255]: "Active Filters:"
- generic [ref=f1e256]: "[]"
- region "Addon panel" [ref=e312]:
- heading "Addon panel" [level=2] [ref=e313]
- generic [ref=e314]:
- generic [ref=e315]:
- generic [ref=e316]:
- button "Move addon panel to right" [ref=e317] [cursor=pointer]:
- img [ref=e318]
- button "Hide addon panel" [ref=e321] [cursor=pointer]:
- img [ref=e322]
- tablist "Available addons" [ref=e327]:
- tab "Controls" [selected] [ref=e328] [cursor=pointer]:
- generic [ref=e330]: Controls
- tab "Actions" [ref=e331] [cursor=pointer]:
- generic [ref=e333]: Actions
- tab "Interactions" [ref=e334] [cursor=pointer]:
- generic [ref=e336]: Interactions
- tabpanel "Controls" [ref=e337]:
- generic [ref=e344]:
- button "Reset controls" [ref=e346] [cursor=pointer]:
- img [ref=e347]
- table [ref=e349]:
- rowgroup [ref=e350]:
- row "Name Description Default Control" [ref=e351]:
- columnheader "Name" [ref=e352]
- columnheader "Description" [ref=e353]
- columnheader "Default" [ref=e354]
- columnheader "Control" [ref=e355]
- rowgroup [ref=e356]:
- row "columns array - -" [ref=e357]:
- cell "columns" [ref=e358]
- cell "array" [ref=e359]:
- generic [ref=e362]: array
- cell "-" [ref=e363]
- cell "-" [ref=e364]
- row "data array - -" [ref=e365]:
- cell "data" [ref=e366]
- cell "array" [ref=e367]:
- generic [ref=e370]: array
- cell "-" [ref=e371]
- cell "-" [ref=e372]
- row "getRowId function - -" [ref=e373]:
- cell "getRowId" [ref=e374]
- cell "function" [ref=e375]:
- generic [ref=e378]: function
- cell "-" [ref=e379]
- cell "-" [ref=e380]
- row "height number - -" [ref=e381]:
- cell "height" [ref=e382]
- cell "number" [ref=e383]:
- generic [ref=e386]: number
- cell "-" [ref=e387]
- cell "-" [ref=e388]
```

File diff suppressed because one or more lines are too long

17
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
'@warkypublic/artemis-kit':
specifier: ^1.0.10
version: 1.0.10
'@warkypublic/resolvespec-js':
specifier: ^1.0.1
version: 1.0.1
'@warkypublic/zustandsyncstore':
specifier: ^1.0.0
version: 1.0.0(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.14)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)))
@@ -1509,6 +1512,10 @@ packages:
resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==}
engines: {node: '>=14.16'}
'@warkypublic/resolvespec-js@1.0.1':
resolution: {integrity: sha512-uXP1HouxpOKXfwE6qpy0gCcrMPIgjDT53aVGkfork4QejRSunbKWSKKawW2nIm7RnyFhSjPILMXcnT5xUiXOew==}
engines: {node: '>=18'}
'@warkypublic/zustandsyncstore@1.0.0':
resolution: {integrity: sha512-hvd4Xrn5btEPjJwNgX52ONoZHnAJdF3NcoTK3GJMVrullcZ+tS2W/SCWIa8vTiYBEhdNyavxsNDZT2x/C9GmVg==}
peerDependencies:
@@ -3819,6 +3826,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -5516,6 +5527,10 @@ snapshots:
semver: 7.7.3
uuid: 11.1.0
'@warkypublic/resolvespec-js@1.0.1':
dependencies:
uuid: 13.0.0
'@warkypublic/zustandsyncstore@1.0.0(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.14)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)))':
dependencies:
'@warkypublic/artemis-kit': 1.0.10
@@ -8059,6 +8074,8 @@ snapshots:
uuid@11.1.0: {}
uuid@13.0.0: {}
vary@1.1.2: {}
vite-plugin-dts@4.5.4(@types/node@25.2.3)(rollup@4.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.6))):

View File

@@ -1,56 +1,148 @@
# Griddy - Implementation Context
## What Is This
Griddy is a new data grid component in the Oranguru package (`@warkypublic/oranguru`), replacing Glide Data Grid (used by Gridler) with TanStack Table + TanStack Virtual.
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): owns sorting, filtering, pagination, row selection, column visibility, grouping state
- **@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` — same pattern as Gridler's `GridlerStore.tsx`
- **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 (the sync happens at runtime but TS needs the types)
- `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
<GriddyInner> // sets up useReactTable + useVirtualizer
<SearchOverlay /> // Ctrl+F search (Mantine TextInput)
<div tabIndex={0}> // scroll container, keyboard target
<TableHeader /> // renders table.getHeaderGroups()
<VirtualBody /> // maps virtualizer items → TableRow
<TableRow /> // focus/selection CSS, click handler
<TableCell /> // flexRender or Mantine Checkbox
</div>
</GriddyInner>
<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>
```
## Key Files
## 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
| File | Purpose |
|------|---------|
| `core/types.ts` | All interfaces: GriddyColumn, GriddyProps, GriddyRef, GriddyUIState, SelectionConfig, SearchConfig, etc. |
| `core/constants.ts` | CSS class names, defaults (row height 36, overscan 10, page size 50) |
| `core/columnMapper.ts` | Maps GriddyColumn → TanStack ColumnDef. Uses `accessorKey` for strings, `accessorFn` for functions. Auto-prepends checkbox column for selection. |
| `core/GriddyStore.ts` | createSyncStore with GriddyStoreState. Exports `GriddyProvider` and `useGriddyStore`. |
| `core/Griddy.tsx` | Main component. GriddyInner reads props from store, creates useReactTable + useVirtualizer, wires keyboard nav. |
| `rendering/VirtualBody.tsx` | Virtual row rendering. **Important**: all hooks must be before early return (hooks violation fix). |
| `rendering/TableHeader.tsx` | Header with sort indicators, resize handles, select-all checkbox. |
| `rendering/TableRow.tsx` | Row with focus/selection styling, click-to-select. |
| `rendering/TableCell.tsx` | Cell rendering via flexRender, checkbox for selection column. |
| `features/keyboard/useKeyboardNavigation.ts` | Full keyboard handler with ref to latest state. |
| `features/search/SearchOverlay.tsx` | Ctrl+F search overlay with debounced global filter. |
| `styles/griddy.module.css` | CSS Modules with custom properties for theming. |
| `Griddy.stories.tsx` | Storybook stories: Basic, LargeDataset, SingleSelection, MultiSelection, WithSearch, KeyboardNavigation. |
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
@@ -61,296 +153,43 @@ Griddy is a new data grid component in the Oranguru package (`@warkypublic/orang
- Ctrl+A: select all (multi mode)
- Ctrl+F: open search overlay
- Ctrl+E / Enter: enter edit mode
- Ctrl+S: toggle selection mode
- Escape: close search / cancel edit / clear selection
## Selection Modes
- `'none'`: no selection
- `'single'`: one row at a time (TanStack `enableMultiRowSelection: false`)
- `'multi'`: multiple rows, checkbox column, shift+click range, ctrl+a
## 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 (enables auto-detect), `sortingFn: 'auto'` for function accessors.
3. **createSyncStore typing**: Props synced at runtime via `$sync` but TypeScript only sees `GriddyStoreState`. All prop fields must be declared in the store state interface.
4. **useGriddyStore has no .getState()**: It's a context-based hook, not a vanilla zustand store. Use `useRef` to track latest state for imperative access in event handlers.
5. **Keyboard focus must scroll**; When keyboard focus changes off screen the screen must scroll with
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 (not raw HTML):
- `Checkbox` from `@mantine/core` for row/header checkboxes
- `TextInput` from `@mantine/core` for search input
- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `Popover`, `Menu`, `ActionIcon` for filtering (Phase 5)
## Phase 5: Column Filtering UI (COMPLETE)
### User Interaction Pattern
1. **Filter Status Indicator**: Gray filter icon in each column header (disabled, non-clickable)
2. **Right-Click Context Menu**: Shows on header right-click with options:
- `Sort` — Toggle column sorting
- `Reset Sorting` — Clear sort (shown only if column is sorted)
- `Reset Filter` — Clear filters (shown only if column has active filter)
- `Open Filters` — Opens filter popover
3. **Filter Popover**: Opened from "Open Filters" menu item
- Positioned below header
- Contains filter operator dropdown and value input(s)
- Apply and Clear buttons
- Filter type determined by `column.filterConfig.type`
### Filter Types & Operators
| Type | Operators | Input Component |
|------|-----------|-----------------|
| `text` | contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty | TextInput with debounce |
| `number` | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between | NumberInput (single or dual for between) |
| `enum` | includes, excludes, isEmpty | MultiSelect with custom options |
| `boolean` | isTrue, isFalse, isEmpty | Radio.Group (True/False/All) |
### API
```typescript
interface FilterConfig {
type: 'text' | 'number' | 'boolean' | 'enum'
operators?: FilterOperator[] // custom operators (optional)
enumOptions?: Array<{ label: string; value: any }> // for enum type
}
// Usage in column definition:
{
id: 'firstName',
accessor: 'firstName',
header: 'First Name',
filterable: true,
filterConfig: { type: 'text' }
}
```
### Key Implementation Details
- **Default filterFn**: Automatically assigned when `filterable: true` and no custom `filterFn` provided
- **Operator-based filtering**: Uses `createOperatorFilter()` that delegates to type-specific implementations
- **Debouncing**: Text inputs debounced 300ms to reduce re-renders
- **TanStack Integration**: Uses `column.setFilterValue()` and `column.getFilterValue()`
- **AND Logic**: Multiple column filters applied together (AND by default)
- **Controlled State**: Filter state managed by parent via `columnFilters` prop and `onColumnFiltersChange` callback
### Files Structure (Phase 5)
```
src/Griddy/features/filtering/
├── types.ts # FilterOperator, FilterConfig, FilterValue
├── operators.ts # TEXT_OPERATORS, NUMBER_OPERATORS, etc.
├── filterFunctions.ts # TanStack FilterFn implementations
├── FilterInput.tsx # Text/number input with debounce
├── FilterSelect.tsx # Multi-select for enums
├── FilterBoolean.tsx # Radio group for booleans
├── ColumnFilterButton.tsx # Status indicator icon
├── ColumnFilterPopover.tsx # Popover UI container
├── ColumnFilterContextMenu.tsx # Right-click context menu
└── index.ts # Public exports
```
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: Core foundation + TanStack Table
- [x] Phase 2: Virtualization + keyboard navigation
- [x] Phase 3: Row selection (single + multi)
- [x] Phase 4: Search (Ctrl+F overlay)
- [x] Sorting (click header)
- [x] Phase 5: Column filtering UI (COMPLETE ✅)
- Right-click context menu on headers
- Sort, Reset Sort, Reset Filter, Open Filters menu items
- Text, number, enum, boolean filtering
- Filter popover UI with operators
- 6 Storybook stories with examples
- 8 Playwright E2E test cases
- [x] Phase 5.5: Date filtering (COMPLETE ✅)
- Date filter operators: is, isBefore, isAfter, isBetween
- DatePickerInput component integration
- Updated Storybook stories (WithDateFiltering, WithAllFilterTypes, LargeDatasetWithFiltering)
- Filter functions for date comparison
- [x] Server-side filtering/sorting (COMPLETE ✅)
- `manualSorting` and `manualFiltering` props
- `dataCount` prop for total row count
- TanStack Table integration with manual modes
- ServerSideFilteringSorting story demonstrating external data fetching
- [x] Phase 6: In-place editing (COMPLETE ✅)
- 5 built-in editors: TextEditor, NumericEditor, DateEditor, SelectEditor, CheckboxEditor
- EditableCell component with editor mounting
- Keyboard shortcuts: Ctrl+E, Enter (edit), Escape (cancel), Tab (commit and move)
- Double-click to edit
- onEditCommit callback for data mutations
- WithInlineEditing Storybook story
- [x] Phase 7: Pagination (COMPLETE ✅)
- PaginationControl component with Mantine UI
- Client-side pagination (TanStack Table getPaginationRowModel)
- Server-side pagination (onPageChange, onPageSizeChange callbacks)
- Page navigation controls and page size selector
- WithClientSidePagination and WithServerSidePagination stories
- [x] Phase 7: Pagination + remote data adapters (COMPLETE ✅)
- [x] Phase 8: Advanced Features (PARTIAL ✅ - column visibility + CSV export)
- Column visibility menu with checkboxes
- CSV export function (exportToCsv)
- GridToolbar component
- WithToolbar Storybook story
- [x] Phase 9: Polish & Documentation (COMPLETE ✅)
- README.md with API reference
- EXAMPLES.md with TypeScript examples
- THEME.md with theming guide
- 15+ Storybook stories
- Full accessibility (ARIA)
- [ ] Phase 8: Grouping, pinning, column reorder, export
- [ ] Phase 9: Polish, docs, tests
- [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
## Dependencies Added
- `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies)
- `@mantine/dates` ^8.3.14 (Phase 5.5)
- `dayjs` ^1.11.19 (peer dependency for @mantine/dates)
## 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)
## Build & Testing Status
- [x] `pnpm run typecheck` — ✅ PASS (0 errors)
- [x] `pnpm run lint` — ✅ PASS (0 errors in Phase 5 code)
- [x] `pnpm run build` — ✅ PASS
- [x] `pnpm run storybook` — ✅ 6 Phase 5 stories working
- [x] Playwright test suite created (8 E2E test cases)
### Commands
## Commands
```bash
# Run all checks
pnpm run typecheck && pnpm run lint && pnpm run build
# Start Storybook (see filtering stories)
pnpm run storybook
# Install and run Playwright tests
pnpm exec playwright install
pnpm exec playwright test
# Run specific test file
pnpm exec playwright test tests/e2e/filtering-context-menu.spec.ts
# Debug mode
pnpm exec playwright test --debug
# View HTML report
pnpm exec playwright show-report
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
```
## Recent Completions
### Phase 5.5 - Date Filtering
**Files Created**:
- `src/Griddy/features/filtering/FilterDate.tsx` — Date picker with single/range modes
**Files Modified**:
- `types.ts`, `operators.ts`, `filterFunctions.ts`, `ColumnFilterPopover.tsx`, `index.ts`
- `Griddy.stories.tsx` — WithDateFiltering story
### Server-Side Filtering/Sorting
**Files Modified**:
- `src/Griddy/core/types.ts` — Added `manualSorting`, `manualFiltering`, `dataCount` props
- `src/Griddy/core/GriddyStore.ts` — Added props to store state
- `src/Griddy/core/Griddy.tsx` — Integrated manual modes with TanStack Table
- `src/Griddy/Griddy.stories.tsx` — Added ServerSideFilteringSorting story
### Phase 6 - In-Place Editing (COMPLETE ✅)
**Files Created** (7 editors + 1 component):
- `src/Griddy/editors/types.ts` — Editor type definitions
- `src/Griddy/editors/TextEditor.tsx` — Text input editor
- `src/Griddy/editors/NumericEditor.tsx` — Number input editor with min/max/step
- `src/Griddy/editors/DateEditor.tsx` — Date picker editor
- `src/Griddy/editors/SelectEditor.tsx` — Dropdown select editor
- `src/Griddy/editors/CheckboxEditor.tsx` — Checkbox editor
- `src/Griddy/editors/index.ts` — Editor exports
- `src/Griddy/rendering/EditableCell.tsx` — Cell editing wrapper
**Files Modified** (4):
- `core/types.ts` — Added EditorConfig import, editorConfig to GriddyColumn
- `rendering/TableCell.tsx` — Integrated EditableCell, double-click handler, edit mode detection
- `features/keyboard/useKeyboardNavigation.ts` — Enter/Ctrl+E find first editable column
- `Griddy.stories.tsx` — Added WithInlineEditing story
### Phase 7 - Pagination (COMPLETE ✅)
**Files Created** (2):
- `src/Griddy/features/pagination/PaginationControl.tsx` — Pagination UI with navigation + page size selector
- `src/Griddy/features/pagination/index.ts` — Pagination exports
**Files Modified** (3):
- `core/Griddy.tsx` — Integrated PaginationControl, wired pagination callbacks
- `styles/griddy.module.css` — Added pagination styles
- `Griddy.stories.tsx` — Added WithClientSidePagination and WithServerSidePagination stories
**Features**:
- Client-side pagination (10,000 rows in memory)
- Server-side pagination (callbacks trigger data fetch)
- Page navigation (first, prev, next, last)
- Page size selector (10, 25, 50, 100)
- Pagination state integration with TanStack Table
### Phase 8 - Advanced Features (PARTIAL ✅)
**Files Created** (6):
- `src/Griddy/features/export/exportCsv.ts` — CSV export utility functions
- `src/Griddy/features/export/index.ts` — Export module exports
- `src/Griddy/features/columnVisibility/ColumnVisibilityMenu.tsx` — Column toggle menu
- `src/Griddy/features/columnVisibility/index.ts` — Column visibility exports
- `src/Griddy/features/toolbar/GridToolbar.tsx` — Toolbar with export + column visibility
- `src/Griddy/features/toolbar/index.ts` — Toolbar exports
**Files Modified** (4):
- `core/types.ts` — Added showToolbar, exportFilename props
- `core/GriddyStore.ts` — Added toolbar props to store state
- `core/Griddy.tsx` — Integrated GridToolbar component
- `Griddy.stories.tsx` — Added WithToolbar story
**Features Implemented**:
- Column visibility toggle (show/hide columns via menu)
- CSV export (filtered + visible columns)
- Toolbar component (optional, toggleable)
- TanStack Table columnVisibility state integration
**Deferred**: Column pinning, header grouping, data grouping, column reordering
### Phase 9 - Polish & Documentation (COMPLETE ✅)
**Files Created** (3):
- `src/Griddy/README.md` — Comprehensive API documentation and quick start guide
- `src/Griddy/EXAMPLES.md` — TypeScript examples for all major features
- `src/Griddy/THEME.md` — Theming guide with CSS variables
**Documentation Coverage**:
- ✅ API reference with all props documented
- ✅ Keyboard shortcuts table
- ✅ 10+ code examples (basic, editing, filtering, pagination, server-side)
- ✅ TypeScript integration patterns
- ✅ Theme system with dark mode, high contrast, brand themes
- ✅ Performance notes (10k+ rows, 60fps)
- ✅ Accessibility (ARIA, keyboard navigation)
- ✅ Browser support
**Storybook Stories** (15 total):
- Basic, LargeDataset
- SingleSelection, MultiSelection, LargeMultiSelection
- WithSearch, KeyboardNavigation
- WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithDateFiltering, WithAllFilterTypes, LargeDatasetWithFiltering
- ServerSideFilteringSorting
- WithInlineEditing
- WithClientSidePagination, WithServerSidePagination
- WithToolbar
**Implementation Complete**: All 9 phases finished!
## Resume Instructions (When Returning)
1. **Run full build check**:
```bash
pnpm run typecheck && pnpm run lint && pnpm run build
```
2. **Start Storybook to verify Phase 5**:
```bash
pnpm run storybook
# Open http://localhost:6006
# Check stories: WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithAllFilterTypes, LargeDatasetWithFiltering
```
3. **Run Playwright tests** (requires Storybook running in another terminal):
```bash
pnpm exec playwright test
```
4. **Next task**: Begin Phase 5.5 (Date Filtering) with explicit user approval

File diff suppressed because it is too large Load Diff

View File

@@ -1,261 +0,0 @@
# Griddy - Implementation Summary
## Project Completion ✅
**Griddy** is a feature-complete, production-ready data grid component built on TanStack Table and TanStack Virtual.
## Implementation Status: 9/9 Phases Complete (100%)
### ✅ Phase 1: Core Foundation + TanStack Table
- TanStack Table integration with column mapping
- Basic rendering with flexRender
- GriddyProvider and GriddyStore (createSyncStore pattern)
- Type-safe column definitions
### ✅ Phase 2: Virtualization + Keyboard Navigation
- TanStack Virtual integration (10,000+ row performance)
- Full keyboard navigation (Arrow keys, Page Up/Down, Home/End)
- Focused row indicator with auto-scroll
- 60 fps scrolling performance
### ✅ Phase 3: Row Selection
- Single and multi-selection modes
- Checkbox column (auto-prepended)
- Keyboard selection (Space, Shift+Arrow, Ctrl+A)
- Click and Shift+Click range selection
### ✅ Phase 4: Search
- Ctrl+F search overlay
- Global filter integration
- Debounced input (300ms)
- Search highlighting (prepared for future implementation)
### ✅ Phase 5: Sorting & Filtering
- Single and multi-column sorting
- Sort indicators in headers
- 5 filter types: text, number, enum, boolean, date
- 20+ filter operators
- Right-click context menu
- Filter popover UI with Apply/Clear buttons
- Server-side sort/filter support (manualSorting, manualFiltering)
### ✅ Phase 6: In-Place Editing
- 5 built-in editors: Text, Number, Date, Select, Checkbox
- EditableCell component with editor mounting
- Keyboard editing (Ctrl+E, Enter, Escape, Tab)
- Double-click to edit
- onEditCommit callback
### ✅ Phase 7: Pagination
- Client-side pagination (10,000+ rows in memory)
- Server-side pagination (callbacks)
- PaginationControl UI (first, prev, next, last navigation)
- Page size selector (10, 25, 50, 100)
- TanStack Table pagination integration
### ✅ Phase 8: Advanced Features (Partial)
- Column visibility toggle menu
- CSV export (exportToCsv, getTableCsv)
- GridToolbar component
- **Deferred**: Column pinning, header grouping, data grouping, column reordering
### ✅ Phase 9: Polish & Documentation
- README.md with API reference
- EXAMPLES.md with 10+ TypeScript examples
- THEME.md with theming guide
- 15+ Storybook stories
- Full ARIA compliance
## Features Delivered
### Core Features
- ⌨️ **Keyboard-first** — Full navigation with 15+ shortcuts
- 🚀 **Virtual scrolling** — Handle 10,000+ rows at 60fps
- 📝 **Inline editing** — 5 editor types with keyboard support
- 🔍 **Search** — Ctrl+F overlay with global filter
- 🎯 **Selection** — Single/multi modes with keyboard
- 📊 **Sorting** — Single and multi-column
- 🔎 **Filtering** — 5 types, 20+ operators
- 📄 **Pagination** — Client-side and server-side
- 💾 **CSV Export** — Export filtered data
- 👁️ **Column visibility** — Show/hide columns
### Technical Highlights
- **TypeScript** — Fully typed with generics
- **Performance** — 60fps with 10k+ rows
- **Accessibility** — WAI-ARIA compliant
- **Theming** — CSS variables system
- **Bundle size** — ~45KB gzipped
- **Zero runtime** — No data mutations, callback-driven
## File Statistics
### Files Created: 58
- **Core**: 8 files (Griddy.tsx, types.ts, GriddyStore.ts, etc.)
- **Rendering**: 5 files (VirtualBody, TableHeader, TableRow, TableCell, EditableCell)
- **Editors**: 7 files (5 editors + types + index)
- **Features**: 20+ files (filtering, search, keyboard, pagination, toolbar, export, etc.)
- **Documentation**: 5 files (README, EXAMPLES, THEME, plan.md, CONTEXT.md)
- **Tests**: 1 E2E test suite (8 test cases)
- **Stories**: 15+ Storybook stories
### Lines of Code: ~5,000+
- TypeScript/TSX: ~4,500
- CSS: ~300
- Markdown: ~1,200
## Dependencies
### Required Peer Dependencies
- `react` >= 19.0.0
- `react-dom` >= 19.0.0
- `@tanstack/react-table` >= 8.0.0
- `@tanstack/react-virtual` >= 3.13.0
- `@mantine/core` >= 8.0.0
- `@mantine/dates` >= 8.0.0
- `@mantine/hooks` >= 8.0.0
- `dayjs` >= 1.11.0
### Internal Dependencies
- `@warkypublic/zustandsyncstore` — Store synchronization
## Browser Support
- ✅ Chrome/Edge (latest 2 versions)
- ✅ Firefox (latest 2 versions)
- ✅ Safari (latest 2 versions)
## Performance Benchmarks
- **10,000 rows**: 60fps scrolling, <100ms initial render
- **Filtering**: <50ms for 10k rows
- **Sorting**: <100ms for 10k rows
- **Bundle size**: ~45KB gzipped (excluding peers)
## Storybook Stories (15)
1. **Basic** — Simple table with sorting
2. **LargeDataset** — 10,000 rows virtualized
3. **SingleSelection** — Single row selection
4. **MultiSelection** — Multi-row selection with keyboard
5. **LargeMultiSelection** — 10k rows with selection
6. **WithSearch** — Ctrl+F search overlay
7. **KeyboardNavigation** — Keyboard shortcuts demo
8. **WithTextFiltering** — Text filters
9. **WithNumberFiltering** — Number filters
10. **WithEnumFiltering** — Enum multi-select filters
11. **WithBooleanFiltering** — Boolean radio filters
12. **WithDateFiltering** — Date picker filters
13. **WithAllFilterTypes** — All filter types combined
14. **LargeDatasetWithFiltering** — 10k rows with filters
15. **ServerSideFilteringSorting** — External data fetching
16. **WithInlineEditing** — Editable cells demo
17. **WithClientSidePagination** — Memory pagination
18. **WithServerSidePagination** — External pagination
19. **WithToolbar** — Column visibility + CSV export
## API Surface
### Main Component
- `<Griddy />` — Main grid component with 25+ props
### Hooks
- `useGriddyStore` — Access store from context
### Utilities
- `exportToCsv()` — Export table to CSV
- `getTableCsv()` — Get CSV string
### Components
- `GridToolbar` — Optional toolbar
- `PaginationControl` — Pagination UI
- `ColumnVisibilityMenu` — Column toggle
- `SearchOverlay` — Search UI
- `EditableCell` — Cell editor wrapper
- 5 Editor components
### Types
- `GriddyColumn<T>` — Column definition
- `GriddyProps<T>` — Main props
- `GriddyRef<T>` — Imperative ref
- `SelectionConfig` — Selection config
- `SearchConfig` — Search config
- `PaginationConfig` — Pagination config
- `FilterConfig` — Filter config
- `EditorConfig` — Editor config
## Accessibility (ARIA)
### Roles
-`role="grid"` on container
-`role="row"` on rows
-`role="gridcell"` on cells
-`role="columnheader"` on headers
### Attributes
-`aria-selected` on selected rows
-`aria-activedescendant` for focused row
-`aria-sort` on sorted columns
-`aria-label` on interactive elements
-`aria-rowcount` for total rows
### Keyboard
- ✅ Full keyboard navigation
- ✅ Focus indicators
- ✅ Screen reader compatible
## Future Enhancements (Deferred)
### Phase 8 Remaining
- Column pinning (left/right sticky columns)
- Header grouping (multi-level headers)
- Data grouping (hierarchical data)
- Column reordering (drag-and-drop)
### Phase 6 Deferred
- Validation system for editors
- Tab-to-next-editable-cell navigation
- Undo/redo functionality
### General
- Column virtualization (horizontal scrolling)
- Tree/hierarchical data
- Copy/paste support
- Master-detail expandable rows
- Cell-level focus (left/right navigation)
## Lessons Learned
### Architecture Wins
1. **TanStack Table** — Excellent headless table library, handles all logic
2. **TanStack Virtual** — Perfect for large datasets
3. **Zustand + createSyncStore** — Clean state management pattern
4. **Column mapper pattern** — Simplifies user-facing API
5. **Callback-driven** — No mutations, pure data flow
### Development Patterns
1. **Phase-by-phase** — Incremental development kept scope manageable
2. **Storybook-driven** — Visual testing during development
3. **TypeScript generics** — Type safety with flexibility
4. **CSS variables** — Easy theming without JS
5. **Modular features** — Each feature in its own directory
## Conclusion
**Griddy is production-ready** with:
- ✅ All core features implemented
- ✅ Comprehensive documentation
- ✅ 15+ working Storybook stories
- ✅ Full TypeScript support
- ✅ Accessibility compliance
- ✅ Performance validated (10k+ rows)
**Ready for:**
- Production use in Oranguru package
- External users via NPM
- Further feature additions
- Community contributions
**Next Steps:**
- Publish to NPM as `@warkypublic/oranguru`
- Add to package README
- Monitor for bug reports
- Consider deferred features based on user feedback

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

View File

@@ -15,145 +15,179 @@ import {
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table'
import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
} from '@tanstack/react-table';
import React, {
forwardRef,
type Ref,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { GriddyProps, GriddyRef } from './types'
import type { GriddyProps, GriddyRef } from './types';
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation'
import { PaginationControl } from '../features/pagination'
import { SearchOverlay } from '../features/search/SearchOverlay'
import { GridToolbar } from '../features/toolbar'
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'
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}>
<GriddyInner tableRef={ref} />
<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 manualSorting = useGriddyStore((s) => s.manualSorting)
const manualFiltering = useGriddyStore((s) => s.manualFiltering)
const dataCount = useGriddyStore((s) => s.dataCount)
const setTable = useGriddyStore((s) => s.setTable)
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 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
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],
)
[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>([])
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 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 [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
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)
paginationConfig.onPageChange(next.pageIndex);
}
if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) {
paginationConfig.onPageSizeChange(next.pageSize)
paginationConfig.onPageSizeChange(next.pageSize);
}
}
return next
})
}
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
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'
const enableRowSelection = selection ? selection.mode !== 'none' : false;
const enableMultiRowSelection = selection?.mode === 'multi';
// ─── TanStack Table Instance ───
const table = useReactTable<T>({
columns,
data: (data ?? []) as T[],
data: transformedData as T[],
enableColumnResizing: true,
enableExpanding: true,
enableFilters: true,
@@ -164,12 +198,27 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
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)),
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,
@@ -194,12 +243,14 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
sorting,
...(paginationConfig?.enabled ? { pagination: internalPagination } : {}),
},
...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}),
...(paginationConfig?.enabled && paginationConfig.type !== 'offset'
? { getPaginationRowModel: getPaginationRowModel() }
: {}),
columnResizeMode: 'onChange',
})
});
// ─── Scroll Container Ref ───
const scrollRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null);
// ─── TanStack Virtual ───
const virtualizer = useGridVirtualizer({
@@ -207,16 +258,42 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
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])
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()
const storeState = useGriddyStore();
useKeyboardNavigation({
editingEnabled: !!onEditCommit,
@@ -225,60 +302,66 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
selection,
storeState,
table,
tree,
virtualizer,
})
});
// ─── Set initial focus when data loads ───
const rowCount = table.getRowModel().rows.length
const rowCount = table.getRowModel().rows.length;
useEffect(() => {
setTotalRows(rowCount)
setTotalRows(rowCount);
if (rowCount > 0 && focusedRowIndex === null) {
setFocusedRow(0)
setFocusedRow(0);
}
}, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow])
}, [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])
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
const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null;
const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined;
return (
<div
@@ -289,9 +372,12 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
role="grid"
>
{search?.enabled && <SearchOverlay />}
{advancedSearch?.enabled && <AdvancedSearchPanel table={table} />}
{showToolbar && (
<GridToolbar
exportFilename={exportFilename}
filterPresets={filterPresets}
persistenceKey={persistenceKey}
table={table}
/>
)}
@@ -302,18 +388,22 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
tabIndex={enableKeyboard ? 0 : undefined}
>
<TableHeader />
<VirtualBody />
{isLoading && (!data || data.length === 0) ? (
<GriddyLoadingSkeleton />
) : (
<>
<VirtualBody />
{isLoading && <GriddyLoadingOverlay />}
</>
)}
</div>
{paginationConfig?.enabled && (
<PaginationControl
pageSizeOptions={paginationConfig.pageSizeOptions}
table={table}
/>
<PaginationControl pageSizeOptions={paginationConfig.pageSizeOptions} table={table} />
)}
</div>
)
);
}
export const Griddy = forwardRef(_Griddy) as <T>(
props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>>
) => React.ReactElement
) => React.ReactElement;

View File

@@ -1,10 +1,27 @@
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 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 { createSyncStore } from '@warkypublic/zustandsyncstore';
import type { DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingConfig, InfiniteScrollConfig, PaginationConfig, SearchConfig, SelectionConfig } from './types'
import type {
AdvancedSearchConfig,
DataAdapter,
GriddyColumn,
GriddyProps,
GriddyUIState,
GroupingConfig,
InfiniteScrollConfig,
PaginationConfig,
SearchConfig,
SelectionConfig,
TreeConfig,
} from './types';
// ─── Store State ─────────────────────────────────────────────────────────────
@@ -14,47 +31,69 @@ import type { DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingCon
* Fields from GriddyProps must be declared here so TypeScript can see them.
*/
export interface GriddyStoreState extends GriddyUIState {
_scrollRef: HTMLDivElement | null
_scrollRef: HTMLDivElement | null;
// ─── Internal refs (set imperatively) ───
_table: null | Table<any>
_virtualizer: null | Virtualizer<HTMLDivElement, Element>
className?: string
columnFilters?: ColumnFiltersState
columns?: GriddyColumn<any>[]
columnPinning?: ColumnPinningState
onColumnPinningChange?: (pinning: ColumnPinningState) => void
data?: any[]
exportFilename?: string
dataAdapter?: DataAdapter<any>
dataCount?: number
getRowId?: (row: any, index: number) => string
grouping?: GroupingConfig
height?: number | string
infiniteScroll?: InfiniteScrollConfig
keyboardNavigation?: boolean
manualFiltering?: boolean
manualSorting?: boolean
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
onRowSelectionChange?: (selection: RowSelectionState) => void
onSortingChange?: (sorting: SortingState) => void
overscan?: number
pagination?: PaginationConfig
persistenceKey?: string
rowHeight?: number
rowSelection?: RowSelectionState
search?: SearchConfig
_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;
selection?: SelectionConfig
showToolbar?: boolean
setScrollRef: (el: HTMLDivElement | null) => void
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
setTable: (table: Table<any>) => void;
setTreeChildrenCache: (nodeId: string, children: any[]) => void;
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void
sorting?: SortingState
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
uniqueId?: string;
}
// ─── Create Store ────────────────────────────────────────────────────────────
@@ -62,50 +101,75 @@ export interface GriddyStoreState extends GriddyUIState {
export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
GriddyStoreState,
GriddyProps<any>
>(
(set, get) => ({
_scrollRef: null,
// ─── Internal Refs ───
_table: null,
>((set, get) => ({
_scrollRef: null,
// ─── Internal Refs ───
_table: null,
_virtualizer: null,
focusedColumnId: null,
// ─── Focus State ───
focusedRowIndex: null,
_virtualizer: null,
appendData: (data) => set((state) => ({ data: [...(state.data ?? []), ...data] })),
error: null,
focusedColumnId: null,
// ─── Focus State ───
focusedRowIndex: null,
// ─── Mode State ───
isEditing: false,
// ─── 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 })
},
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 }),
setEditing: (editing) => set({ isEditing: editing }),
setFocusedColumn: (id) => set({ focusedColumnId: id }),
// ─── Actions ───
setFocusedRow: (index) => set({ focusedRowIndex: index }),
setScrollRef: (el) => set({ _scrollRef: el }),
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 }),
setSearchOpen: (open) => set({ isSearchOpen: open }),
setSelecting: (selecting) => set({ isSelecting: selecting }),
// ─── Internal Ref Setters ───
setTable: (table) => set({ _table: table }),
setSelecting: (selecting) => set({ isSelecting: selecting }),
// ─── Internal Ref Setters ───
setTable: (table) => set({ _table: table }),
setTotalRows: (count) => set({ totalRows: count }),
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
// ─── Row Count ───
totalRows: 0,
}),
)
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

@@ -1,22 +1,85 @@
import type { ColumnDef } from '@tanstack/react-table'
import type { ColumnDef } from '@tanstack/react-table';
import type { GriddyColumn, SelectionConfig } from './types'
import type { GriddyColumn, SelectionConfig } from './types';
import { createOperatorFilter } from '../features/filtering'
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants'
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
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 isStringAccessor = typeof col.accessor !== 'function';
const def: ColumnDef<T> = {
id: col.id,
@@ -37,80 +100,33 @@ function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> {
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
def.sortingFn = col.sortFn;
} else if (!isStringAccessor && col.sortable !== false) {
// Use alphanumeric sorting for function accessors
def.sortingFn = 'alphanumeric'
def.sortingFn = 'alphanumeric';
}
if (col.filterFn) {
def.filterFn = col.filterFn
def.filterFn = col.filterFn;
} else if (col.filterable) {
def.filterFn = createOperatorFilter()
}
return def
}
/**
* 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> = {
header: groupName,
id: `group-${groupName}`,
columns: groupColumns.map(col => mapSingleColumn(col)),
}
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)
def.filterFn = createOperatorFilter();
}
return mapped
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

@@ -1,270 +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 {
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'
import type { EditorConfig } from '../editors';
import type { FilterConfig } from '../features/filtering';
// ─── Column Definition ───────────────────────────────────────────────────────
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode
export interface AdvancedSearchConfig {
enabled: boolean;
}
// ─── Cell Rendering ──────────────────────────────────────────────────────────
export interface DataAdapter<T> {
delete?: (row: T) => Promise<void>
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>
save?: (row: T) => Promise<void>
}
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode;
export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode
export interface DataAdapter<T> {
delete?: (row: T) => Promise<void>;
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>;
save?: (row: T) => Promise<void>;
}
// ─── Editors ─────────────────────────────────────────────────────────────────
export interface EditorProps<T> {
column: GriddyColumn<T>
onCancel: () => void
onCommit: (newValue: unknown) => void
onMoveNext: () => void
onMovePrev: () => void
row: T
rowIndex: number
value: unknown
}
export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode;
export interface FetchConfig {
cursor?: string
filters?: ColumnFiltersState
globalFilter?: string
page?: number
pageSize?: number
sorting?: SortingState
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 GriddyColumn<T> {
accessor: ((row: T) => unknown) | keyof T
aggregationFn?: 'sum' | 'min' | 'max' | 'mean' | 'median' | 'unique' | 'uniqueCount' | 'count'
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>
searchable?: boolean
sortable?: boolean
sortFn?: SortingFn<T>
width?: number
export interface FetchConfig {
cursor?: string;
filters?: ColumnFiltersState;
globalFilter?: string;
page?: number;
pageSize?: number;
sorting?: SortingState;
}
// ─── Search ──────────────────────────────────────────────────────────────────
export interface GriddyDataSource<T> {
data: T[]
error?: Error
isLoading?: boolean
pageInfo?: { cursor?: string; hasNextPage: boolean; }
total?: number
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
children?: ReactNode;
// ─── Styling ───
className?: string
// ─── Toolbar ───
/** Show toolbar with export and column visibility controls. Default: false */
showToolbar?: boolean
/** Export filename. Default: 'export.csv' */
exportFilename?: string
className?: string;
// ─── Filtering ───
/** Controlled column filters state */
columnFilters?: ColumnFiltersState
/** Column definitions */
columns: GriddyColumn<T>[]
columnFilters?: ColumnFiltersState;
/** Controlled column pinning state */
columnPinning?: ColumnPinningState
onColumnPinningChange?: (pinning: ColumnPinningState) => void
columnPinning?: ColumnPinningState;
/** Column definitions */
columns: GriddyColumn<T>[];
/** Data array */
data: T[]
data: T[];
// ─── Data Adapter ───
dataAdapter?: DataAdapter<T>
dataAdapter?: DataAdapter<T>;
/** Total row count (for server-side pagination/filtering). If provided, enables manual mode. */
dataCount?: number
/** Stable row identity function */
getRowId?: (row: T, index: number) => string
// ─── Grouping ───
grouping?: GroupingConfig
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
height?: number | string;
// ─── Infinite Scroll ───
/** Infinite scroll configuration */
infiniteScroll?: InfiniteScrollConfig
infiniteScroll?: InfiniteScrollConfig;
// ─── Loading ───
/** Show loading skeleton/overlay. Default: false */
isLoading?: boolean;
// ─── Keyboard ───
/** Enable keyboard navigation. Default: true */
keyboardNavigation?: boolean
keyboardNavigation?: boolean;
/** Manual filtering mode - filtering handled externally (server-side). Default: false */
manualFiltering?: boolean
manualFiltering?: boolean;
/** Manual sorting mode - sorting handled externally (server-side). Default: false */
manualSorting?: boolean
manualSorting?: boolean;
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
onColumnPinningChange?: (pinning: ColumnPinningState) => void;
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
// ─── Editing ───
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
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
onRowSelectionChange?: (selection: RowSelectionState) => void;
onSortingChange?: (sorting: SortingState) => void
onSortingChange?: (sorting: SortingState) => void;
/** Overscan row count. Default: 10 */
overscan?: number
overscan?: number;
// ─── Pagination ───
pagination?: PaginationConfig
pagination?: PaginationConfig;
// ─── Persistence ───
/** localStorage key prefix for persisting column layout */
persistenceKey?: string
persistenceKey?: string;
// ─── Virtualization ───
/** Row height in pixels. Default: 36 */
rowHeight?: number
rowHeight?: number;
/** Controlled row selection state */
rowSelection?: RowSelectionState
rowSelection?: RowSelectionState;
// ─── Search ───
search?: SearchConfig
search?: SearchConfig;
// ─── Selection ───
/** Selection configuration */
selection?: SelectionConfig
selection?: SelectionConfig;
// ─── Toolbar ───
/** Show toolbar with export and column visibility controls. Default: false */
showToolbar?: boolean;
// ─── Sorting ───
/** Controlled sorting state */
sorting?: SortingState
sorting?: SortingState;
// ─── Tree/Hierarchical Data ───
/** Tree/hierarchical data configuration */
tree?: TreeConfig<T>;
/** Unique identifier for persistence */
uniqueId?: string
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
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
focusedColumnId: null | string;
// Focus
focusedRowIndex: null | number
focusedRowIndex: null | number;
// Modes
isEditing: boolean
isSearchOpen: boolean
isSelecting: boolean
isEditing: boolean;
isSearchOpen: boolean;
isSelecting: boolean;
moveFocus: (direction: 'down' | 'up', amount: number) => void
moveFocus: (direction: 'down' | 'up', amount: number) => void;
moveFocusToEnd: () => void
moveFocusToStart: () => void
setEditing: (editing: boolean) => void
setFocusedColumn: (id: null | string) => 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
setFocusedRow: (index: null | number) => void;
setSearchOpen: (open: boolean) => void;
setSelecting: (selecting: boolean) => void;
setTotalRows: (count: number) => void;
// Row count (synced from table)
totalRows: number
totalRows: number;
}
export interface GroupingConfig {
columns?: string[]
enabled: boolean
columns?: string[];
enabled: boolean;
}
// ─── Grouping ────────────────────────────────────────────────────────────────
export interface InfiniteScrollConfig {
/** Enable infinite scroll */
enabled: boolean
/** Threshold in rows from the end to trigger loading. Default: 10 */
threshold?: number
/** Callback to load more data. Should update the data array. */
onLoadMore?: () => Promise<void> | void
/** Whether data is currently loading */
isLoading?: boolean
enabled: boolean;
/** Whether there is more data to load */
hasMore?: boolean
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'
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
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
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'
mode: 'multi' | 'none' | 'single';
/** Maintain selection across pagination/sorting. Default: true */
preserveSelection?: boolean
preserveSelection?: boolean;
/** Allow clicking row body to toggle selection. Default: true */
selectOnClick?: boolean
selectOnClick?: boolean;
/** Show checkbox column (auto-added as first column). Default: true when mode !== 'none' */
showCheckbox?: boolean
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 }
export type {
ColumnDef,
ColumnFiltersState,
ColumnOrderState,
ColumnPinningState,
ExpandedState,
GroupingState,
PaginationState,
RowSelectionState,
SortingState,
Table,
VisibilityState,
};

View File

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

View File

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

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

@@ -1,71 +1,81 @@
import type { Column } from '@tanstack/react-table'
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 { Button, Group, Popover, Stack, Text } from '@mantine/core';
import { useState } from 'react';
import type { FilterConfig, FilterValue } from './types'
import type { FilterConfig, FilterValue } from './types';
import { getGriddyColumn } from '../../core/columnMapper'
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'
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
column: Column<any, any>;
onOpenedChange?: (opened: boolean) => void;
opened?: boolean;
}
export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOpened }: ColumnFilterPopoverProps) {
const [internalOpened, setInternalOpened] = useState(false)
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 opened = externalOpened !== undefined ? externalOpened : internalOpened;
const setOpened = (value: boolean) => {
if (externalOpened !== undefined) {
onOpenedChange?.(value)
onOpenedChange?.(value);
} else {
setInternalOpened(value)
setInternalOpened(value);
}
}
};
const [localValue, setLocalValue] = useState<FilterValue | undefined>(
(column.getFilterValue() as FilterValue) || undefined,
)
(column.getFilterValue() as FilterValue) || undefined
);
const griddyColumn = getGriddyColumn(column)
const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig
const griddyColumn = getGriddyColumn(column);
const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig;
if (!filterConfig) {
return null
return null;
}
const handleApply = () => {
column.setFilterValue(localValue)
setOpened(false)
}
column.setFilterValue(localValue);
setOpened(false);
};
const handleClear = () => {
setLocalValue(undefined)
column.setFilterValue(undefined)
setOpened(false)
}
setLocalValue(undefined);
column.setFilterValue(undefined);
setOpened(false);
};
const handleClose = () => {
setOpened(false)
setOpened(false);
// Reset to previous value if popover is closed without applying
setLocalValue((column.getFilterValue() as FilterValue) || undefined)
}
setLocalValue((column.getFilterValue() as FilterValue) || undefined);
};
const operators =
filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type]
const operators = filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type];
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
setOpened(!opened);
};
return (
<Popover onChange={setOpened} onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
<Popover onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
<Popover.Target>
<ColumnFilterButton column={column} />
<ColumnFilterButton column={column} onClick={handleToggle} />
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={280}>
@@ -105,9 +115,17 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
)}
{filterConfig.type === 'date' && (
<FilterDate
onChange={setLocalValue}
operators={operators}
<FilterDate onChange={setLocalValue} operators={operators} value={localValue} />
)}
{filterConfig.quickFilter && (
<QuickFilterDropdown
column={column}
onApply={(val) => {
setLocalValue(val);
column.setFilterValue(val);
setOpened(false);
}}
value={localValue}
/>
)}
@@ -123,5 +141,5 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
</Stack>
</Popover.Dropdown>
</Popover>
)
);
}

View File

@@ -3,6 +3,8 @@
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'
}

View File

@@ -3,7 +3,7 @@ import type { Virtualizer } from '@tanstack/react-virtual'
import { type RefObject, useCallback, useEffect, useRef } from 'react'
import type { GriddyUIState, SearchConfig, SelectionConfig } from '../../core/types'
import type { GriddyUIState, SearchConfig, SelectionConfig, TreeConfig } from '../../core/types'
interface UseKeyboardNavigationOptions<TData = unknown> {
editingEnabled: boolean
@@ -12,6 +12,7 @@ interface UseKeyboardNavigationOptions<TData = unknown> {
selection?: SelectionConfig
storeState: GriddyUIState
table: Table<TData>
tree?: TreeConfig<TData>
virtualizer: Virtualizer<HTMLDivElement, Element>
}
@@ -22,6 +23,7 @@ export function useKeyboardNavigation<TData = unknown>({
selection,
storeState,
table,
tree,
virtualizer,
}: UseKeyboardNavigationOptions<TData>) {
// Keep a ref to the latest store state so the keydown handler always sees fresh state
@@ -114,6 +116,57 @@ export function useKeyboardNavigation<TData = unknown>({
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)
@@ -228,7 +281,7 @@ export function useKeyboardNavigation<TData = unknown>({
))
virtualizer.scrollToIndex(newIndex, { align: 'auto' })
}
}, [table, virtualizer, selection, search, editingEnabled])
}, [table, virtualizer, selection, search, editingEnabled, tree])
useEffect(() => {
const el = scrollRef.current
@@ -237,3 +290,20 @@ export function useKeyboardNavigation<TData = unknown>({
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 { 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

@@ -1,62 +1,111 @@
import { TextInput } from '@mantine/core'
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) {
inputRef.current?.focus()
} else {
setQuery('')
table?.setGlobalFilter(undefined)
// Defer focus to next frame so the input is mounted
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [isSearchOpen, table])
}, [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])
}, [table, debounceMs, addEntry])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// 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()
setSearchOpen(false)
closeSearch()
}
}, [setSearchOpen])
}, [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]}>
<TextInput
aria-label="Search grid"
onChange={(e) => handleChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={inputRef}
size="xs"
value={query}
/>
<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

@@ -5,9 +5,12 @@ 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>
@@ -15,6 +18,8 @@ interface GridToolbarProps<T> {
export function GridToolbar<T>({
exportFilename = 'export.csv',
filterPresets = false,
persistenceKey,
showColumnToggle = true,
showExport = true,
table,
@@ -23,12 +28,15 @@ export function GridToolbar<T>({
exportToCsv(table, exportFilename)
}
if (!showExport && !showColumnToggle) {
if (!showExport && !showColumnToggle && !filterPresets) {
return null
}
return (
<Group gap="xs" justify="flex-end" p="xs" style={{ borderBottom: '1px solid #e0e0e0' }}>
<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"

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]);
}

View File

@@ -1,9 +1,22 @@
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'
// 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,
@@ -19,4 +32,17 @@ export type {
RendererProps,
SearchConfig,
SelectionConfig,
} from './core/types'
} 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';

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,59 @@
import type { Cell } from '@tanstack/react-table'
import type { Cell } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react';
import { CheckboxEditor, DateEditor, NumericEditor, SelectEditor, TextEditor } from '../editors'
import type { EditorConfig } from '../editors'
import { getGriddyColumn } from '../core/columnMapper'
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
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
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())
const [value, setValue] = useState(cell.getValue());
useEffect(() => {
setValue(cell.getValue())
}, [cell])
setValue(cell.getValue());
}, [cell]);
const handleCommit = useCallback((newValue: unknown) => {
setValue(newValue)
onCommitEdit(newValue)
}, [onCommitEdit])
const handleCommit = useCallback(
(newValue: unknown) => {
setValue(newValue);
onCommitEdit(newValue);
},
[onCommitEdit]
);
const handleCancel = useCallback(() => {
setValue(cell.getValue())
onCancelEdit()
}, [cell, onCancelEdit])
setValue(cell.getValue());
onCancelEdit();
}, [cell, onCancelEdit]);
if (!isEditing) {
return null
return null;
}
// Custom editor from column definition
if (customEditor) {
const EditorComponent = customEditor as any
const EditorComponent = customEditor as any;
return (
<EditorComponent
onCancel={handleCancel}
@@ -51,13 +62,35 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
onMovePrev={onMovePrev}
value={value}
/>
)
);
}
// Built-in editors based on editorConfig.type
const editorType = editorConfig.type ?? 'text'
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
@@ -70,18 +103,7 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
step={editorConfig.step}
value={value as number}
/>
)
case 'date':
return (
<DateEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as Date | string}
/>
)
);
case 'select':
return (
@@ -93,18 +115,7 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
options={editorConfig.options ?? []}
value={value}
/>
)
case 'checkbox':
return (
<CheckboxEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as boolean}
/>
)
);
case 'text':
default:
@@ -116,6 +127,6 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
onMovePrev={onMovePrev}
value={value as string}
/>
)
);
}
}

View File

@@ -1,63 +1,81 @@
import { Checkbox } from '@mantine/core'
import { type Cell, flexRender } from '@tanstack/react-table'
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 styles from '../styles/griddy.module.css'
import { EditableCell } from './EditableCell'
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
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 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} />
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 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)
await onEditCommit(cell.row.id, columnId, value);
}
setEditing(false)
setFocusedColumn(null)
}
setEditing(false);
setFocusedColumn(null);
};
const handleCancel = () => {
setEditing(false)
setFocusedColumn(null)
}
setEditing(false);
setFocusedColumn(null);
};
const handleDoubleClick = () => {
if (isEditable) {
setEditing(true)
setFocusedColumn(columnId)
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 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()
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
@@ -65,23 +83,35 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
styles[CSS.cell],
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
].filter(Boolean).join(' ')}
]
.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={{
border: 'none',
background: 'none',
border: 'none',
cursor: 'pointer',
marginRight: 4,
padding: 0,
@@ -102,19 +132,22 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
{flexRender(cell.column.columnDef.cell, cell.getContext())} ({cell.row.subRows.length})
</>
) : isAggregated ? (
flexRender(cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, cell.getContext())
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
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
@@ -122,7 +155,9 @@ function RowCheckbox<T>({ cell }: TableCellProps<T>) {
styles[CSS.cell],
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
].filter(Boolean).join(' ')}
]
.filter(Boolean)
.join(' ')}
role="gridcell"
style={{
left: leftOffset !== undefined ? `${leftOffset}px` : undefined,
@@ -141,5 +176,5 @@ function RowCheckbox<T>({ cell }: TableCellProps<T>) {
size="xs"
/>
</div>
)
);
}

View File

@@ -1,81 +1,85 @@
import { Checkbox } from '@mantine/core'
import { flexRender } from '@tanstack/react-table'
import { useState } from 'react'
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'
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<string | null>(null)
const table = useGriddyStore((s) => s._table);
const [filterPopoverOpen, setFilterPopoverOpen] = useState<null | string>(null);
const [draggedColumn, setDraggedColumn] = useState<null | string>(null);
if (!table) return null
if (!table) return null;
const headerGroups = table.getHeaderGroups()
const headerGroups = table.getHeaderGroups();
const handleDragStart = (e: React.DragEvent, columnId: string) => {
setDraggedColumn(columnId)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', columnId)
}
setDraggedColumn(columnId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', columnId);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
e.preventDefault()
e.preventDefault();
if (!draggedColumn || draggedColumn === targetColumnId) {
setDraggedColumn(null)
return
setDraggedColumn(null);
return;
}
const columnOrder = table.getState().columnOrder
const currentOrder = columnOrder.length ? columnOrder : table.getAllLeafColumns().map(c => c.id)
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)
const draggedIdx = currentOrder.indexOf(draggedColumn);
const targetIdx = currentOrder.indexOf(targetColumnId);
if (draggedIdx === -1 || targetIdx === -1) {
setDraggedColumn(null)
return
setDraggedColumn(null);
return;
}
const newOrder = [...currentOrder]
newOrder.splice(draggedIdx, 1)
newOrder.splice(targetIdx, 0, draggedColumn)
const newOrder = [...currentOrder];
newOrder.splice(draggedIdx, 1);
newOrder.splice(targetIdx, 0, draggedColumn);
table.setColumnOrder(newOrder)
setDraggedColumn(null)
}
table.setColumnOrder(newOrder);
setDraggedColumn(null);
};
const handleDragEnd = () => {
setDraggedColumn(null)
}
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.getAfter('right') : undefined
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
const isDragging = draggedColumn === header.column.id;
const canReorder = !isSelectionCol && !isPinned;
return (
<div
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'}
aria-sort={
sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'
}
className={[
styles[CSS.headerCell],
isSortable ? styles[CSS.headerCellSortable] : '',
@@ -83,7 +87,9 @@ export function TableHeader() {
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(' ')}
]
.filter(Boolean)
.join(' ')}
draggable={canReorder}
key={header.id}
onClick={isSortable ? header.column.getToggleSortingHandler() : undefined}
@@ -137,19 +143,19 @@ export function TableHeader() {
/>
)}
</div>
)
);
})}
</div>
))}
</div>
)
);
}
function SelectAllCheckbox() {
const table = useGriddyStore((s) => s._table)
const selection = useGriddyStore((s) => s.selection)
const table = useGriddyStore((s) => s._table);
const selection = useGriddyStore((s) => s.selection);
if (!table || !selection || selection.mode !== 'multi') return null
if (!table || !selection || selection.mode !== 'multi') return null;
return (
<Checkbox
@@ -159,5 +165,5 @@ function SelectAllCheckbox() {
onChange={table.getToggleAllRowsSelectedHandler()}
size="xs"
/>
)
);
}

View File

@@ -1,68 +1,68 @@
import { useEffect, useRef } from 'react'
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'
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 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
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)
const isLoadingRef = useRef(false);
// Sync row count to store for keyboard navigation bounds
useEffect(() => {
if (rows) {
setTotalRows(rows.length)
setTotalRows(rows.length);
}
}, [rows?.length, setTotalRows])
}, [rows?.length, setTotalRows]);
// Infinite scroll: detect when approaching the end
useEffect(() => {
if (!infiniteScroll?.enabled || !infiniteScroll.onLoadMore || !virtualRows || !rows) {
return
return;
}
const { threshold = 10, hasMore = true, isLoading = false } = infiniteScroll
const { hasMore = true, isLoading = false, threshold = 10 } = infiniteScroll;
// Don't trigger if already loading or no more data
if (isLoading || !hasMore || isLoadingRef.current) {
return
return;
}
// Check if the last rendered virtual row is within threshold of the end
const lastVirtualRow = virtualRows[virtualRows.length - 1]
if (!lastVirtualRow) return
const lastVirtualRow = virtualRows[virtualRows.length - 1];
if (!lastVirtualRow) return;
const lastVirtualIndex = lastVirtualRow.index
const totalRows = rows.length
const distanceFromEnd = totalRows - lastVirtualIndex - 1
const lastVirtualIndex = lastVirtualRow.index;
const totalRows = rows.length;
const distanceFromEnd = totalRows - lastVirtualIndex - 1;
if (distanceFromEnd <= threshold) {
isLoadingRef.current = true
const loadPromise = infiniteScroll.onLoadMore()
isLoadingRef.current = true;
const loadPromise = infiniteScroll.onLoadMore();
if (loadPromise instanceof Promise) {
loadPromise.finally(() => {
isLoadingRef.current = false
})
isLoadingRef.current = false;
});
} else {
isLoadingRef.current = false
isLoadingRef.current = false;
}
}
}, [virtualRows, rows, infiniteScroll])
}, [virtualRows, rows, infiniteScroll]);
if (!table || !virtualizer || !rows || !virtualRows) return null
if (!table || !virtualizer || !rows || !virtualRows) return null;
const showLoadingIndicator = infiniteScroll?.enabled && infiniteScroll.isLoading
const showLoadingIndicator = infiniteScroll?.enabled && infiniteScroll.isLoading;
return (
<div
@@ -75,17 +75,10 @@ export function VirtualBody() {
}}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
if (!row) return null
const row = rows[virtualRow.index];
if (!row) return null;
return (
<TableRow
key={row.id}
row={row}
size={virtualRow.size}
start={virtualRow.start}
/>
)
return <TableRow key={row.id} row={row} size={virtualRow.size} start={virtualRow.start} />;
})}
{showLoadingIndicator && (
<div
@@ -101,5 +94,5 @@ export function VirtualBody() {
</div>
)}
</div>
)
);
}

View File

@@ -334,3 +334,321 @@
.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

@@ -0,0 +1,815 @@
import { expect, test } from '@playwright/test';
// Helper to navigate to a story inside the Storybook iframe
async function gotoStory(page: any, storyId: string) {
await page.goto(`/iframe.html?id=components-griddy--${storyId}&viewMode=story`);
await page.waitForSelector('[role="grid"]', { timeout: 10000 });
}
// ─── 1. Basic Rendering ────────────────────────────────────────────────────────
test.describe('Basic Rendering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'basic');
});
test('renders the grid with rows and columns', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible();
// Header row should exist
const headers = page.locator('[role="columnheader"]');
await expect(headers.first()).toBeVisible();
// Should have all 9 column headers
await expect(headers).toHaveCount(9);
});
test('renders correct column headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
});
test('renders data rows', async ({ page }) => {
// Should render multiple data rows (20 in small dataset)
const rows = page.locator('[role="row"]');
// At least header + some data rows
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('clicking a column header sorts data', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
await idHeader.click();
// After clicking, should have a sort indicator
const sortAttr = await idHeader.getAttribute('aria-sort');
expect(sortAttr).toBeTruthy();
expect(['ascending', 'descending']).toContain(sortAttr);
});
test('clicking column header twice reverses sort', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
await idHeader.click();
const firstSort = await idHeader.getAttribute('aria-sort');
await idHeader.click();
const secondSort = await idHeader.getAttribute('aria-sort');
expect(firstSort).not.toEqual(secondSort);
});
});
// ─── 2. Large Dataset (Virtualization) ─────────────────────────────────────────
test.describe('Large Dataset', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'large-dataset');
});
test('renders without crashing on 10k rows', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible();
const rows = page.locator('[role="row"]');
const count = await rows.count();
// Virtualized: should render far fewer rows than 10,000
expect(count).toBeGreaterThan(1);
expect(count).toBeLessThan(200);
});
test('grid aria-rowcount reflects total data size', async ({ page }) => {
const grid = page.locator('[role="grid"]');
const rowCount = await grid.getAttribute('aria-rowcount');
expect(Number(rowCount)).toBe(10000);
});
});
// ─── 3. Single Selection ────────────────────────────────────────────────────────
test.describe('Single Selection', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'single-selection');
});
test('renders checkboxes in each row', async ({ page }) => {
const checkboxes = page.locator('[role="row"] input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
const count = await checkboxes.count();
expect(count).toBeGreaterThan(0);
});
test('clicking a row selects it', async ({ page }) => {
const firstDataRow = page.locator('[role="row"]').nth(1);
await firstDataRow.click();
await page.waitForTimeout(300);
// Selection state should show in the debug output
const selectionText = page.locator('text=Selected:');
await expect(selectionText).toBeVisible();
});
test('clicking another row deselects previous in single mode', async ({ page }) => {
// Click first data row
const firstDataRow = page.locator('[role="row"]').nth(1);
await firstDataRow.click();
await page.waitForTimeout(300);
// Click second data row
const secondDataRow = page.locator('[role="row"]').nth(2);
await secondDataRow.click();
await page.waitForTimeout(300);
// In single selection, only one should be selected
const checkboxes = page.locator('[role="row"] input[type="checkbox"]:checked');
const checkedCount = await checkboxes.count();
expect(checkedCount).toBeLessThanOrEqual(1);
});
});
// ─── 4. Multi Selection ─────────────────────────────────────────────────────────
test.describe('Multi Selection', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'multi-selection');
});
test('renders select-all checkbox in header', async ({ page }) => {
const selectAllCheckbox = page.locator('[aria-label="Select all rows"]');
await expect(selectAllCheckbox).toBeVisible({ timeout: 5000 });
});
test('selecting multiple rows shows count', async ({ page }) => {
// Click first data row
await page.locator('[role="row"]').nth(1).click();
await page.waitForTimeout(200);
// Shift-click third row to extend selection
await page.locator('[role="row"]').nth(3).click({ modifiers: ['Shift'] });
await page.waitForTimeout(300);
// The selected count should show in the debug output
const countText = page.locator('text=/Selected \\(\\d+ rows\\)/');
await expect(countText).toBeVisible({ timeout: 3000 });
});
test('select-all checkbox selects all rows', async ({ page }) => {
const selectAll = page.locator('[aria-label="Select all rows"]');
await selectAll.click();
await page.waitForTimeout(300);
// Should show all 20 rows selected
await expect(page.locator('text=/Selected \\(20 rows\\)/')).toBeVisible({ timeout: 3000 });
});
});
// ─── 5. Search ──────────────────────────────────────────────────────────────────
test.describe('Search', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-search');
});
test('Ctrl+F opens search overlay', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
test('search filters rows', async ({ page }) => {
const initialRowCount = await page.locator('[role="row"]').count();
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
await page.waitForTimeout(500);
const filteredRowCount = await page.locator('[role="row"]').count();
expect(filteredRowCount).toBeLessThan(initialRowCount);
});
test('Escape closes search overlay', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
await page.keyboard.press('Escape');
await expect(page.locator('[aria-label="Search grid"]')).not.toBeVisible({ timeout: 3000 });
});
test('clearing search restores all rows', async ({ page }) => {
const initialRowCount = await page.locator('[role="row"]').count();
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
await page.waitForTimeout(500);
// Clear the search
await searchInput.fill('');
await page.waitForTimeout(500);
const restoredRowCount = await page.locator('[role="row"]').count();
expect(restoredRowCount).toBeGreaterThanOrEqual(initialRowCount);
});
});
// ─── 6. Keyboard Navigation ────────────────────────────────────────────────────
test.describe('Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'keyboard-navigation');
});
test('ArrowDown moves focus to next row', async ({ page }) => {
// Focus the grid container
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
// Grid should still have focus (not lost)
await expect(page.locator('[role="grid"]')).toBeVisible();
});
test('Space toggles row selection', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
// Move to first data row
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Space');
await page.waitForTimeout(300);
// Should show "Selected: 1 rows" in the debug text
await expect(page.locator('text=/Selected: \\d+ rows/')).toBeVisible({ timeout: 3000 });
});
test('Ctrl+A selects all rows', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+a');
await page.waitForTimeout(300);
await expect(page.locator('text=/Selected: 20 rows/')).toBeVisible({ timeout: 3000 });
});
test('Ctrl+F opens search from keyboard nav story', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
});
// ─── 7. Inline Editing ──────────────────────────────────────────────────────────
test.describe('Inline Editing', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-inline-editing');
});
test('double-click on editable cell enters edit mode', async ({ page }) => {
// Find a First Name cell (column index 1, since ID is 0)
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
await firstNameCell.dblclick();
// An input should appear
const input = page.locator('[role="row"]').nth(1).locator('input');
await expect(input).toBeVisible({ timeout: 3000 });
});
test('Enter commits the edit', async ({ page }) => {
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
const originalText = await firstNameCell.innerText();
await firstNameCell.dblclick();
const input = page.locator('[role="row"]').nth(1).locator('input').first();
await expect(input).toBeVisible({ timeout: 3000 });
await input.fill('TestName');
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
// The cell text should now show the new value
const updatedText = await firstNameCell.innerText();
expect(updatedText).toContain('TestName');
});
test('Escape cancels the edit', async ({ page }) => {
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
const originalText = await firstNameCell.innerText();
await firstNameCell.dblclick();
const input = page.locator('[role="row"]').nth(1).locator('input').first();
await expect(input).toBeVisible({ timeout: 3000 });
await input.fill('CancelledValue');
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// The cell should revert to original value
const restoredText = await firstNameCell.innerText();
expect(restoredText).toBe(originalText);
});
});
// ─── 8. Client-Side Pagination ──────────────────────────────────────────────────
test.describe('Client-Side Pagination', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-client-side-pagination');
});
test('renders pagination controls', async ({ page }) => {
await expect(page.locator('text=Page 1 of')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=Rows per page:')).toBeVisible();
});
test('shows correct initial page info', async ({ page }) => {
// 10,000 rows / 25 per page = 400 pages
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
});
test('next page button navigates forward', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the "next page" button (single chevron right)
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
await nextPageBtn.click();
await page.waitForTimeout(300);
await expect(page.locator('text=Page 2 of 400')).toBeVisible();
});
test('last page button navigates to end', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the "last page" button (double chevron right)
const lastPageBtn = page.locator('[class*="griddy-pagination"] button').nth(3);
await lastPageBtn.click();
await page.waitForTimeout(300);
await expect(page.locator('text=Page 400 of 400')).toBeVisible();
});
test('first page button is disabled on first page', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// First page button (double chevron left) should be disabled
const firstPageBtn = page.locator('[class*="griddy-pagination"] button').first();
await expect(firstPageBtn).toBeDisabled();
});
test('page size selector changes rows per page', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the page size select (Mantine Select renders as textbox)
const pageSizeSelect = page.getByRole('textbox');
await pageSizeSelect.click();
// Select 50 rows per page
await page.locator('[role="option"]', { hasText: '50' }).click();
await page.waitForTimeout(300);
// 10,000 / 50 = 200 pages
await expect(page.locator('text=Page 1 of 200')).toBeVisible({ timeout: 3000 });
});
});
// ─── 9. Server-Side Pagination ──────────────────────────────────────────────────
test.describe('Server-Side Pagination', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-server-side-pagination');
});
test('renders data after initial load', async ({ page }) => {
// Wait for server-side data to load (300ms delay)
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('shows server state info', async ({ page }) => {
await expect(page.locator('text=Total Rows: 10000')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible();
});
test('navigating pages updates server state', async ({ page }) => {
await expect(page.locator('text=Current Page: 1')).toBeVisible({ timeout: 5000 });
// Navigate to next page
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
await nextPageBtn.click();
await expect(page.locator('text=Current Page: 2')).toBeVisible({ timeout: 5000 });
});
});
// ─── 10. Toolbar ────────────────────────────────────────────────────────────────
test.describe('Toolbar', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-toolbar');
});
test('renders export button', async ({ page }) => {
const exportBtn = page.locator('[aria-label="Export to CSV"]');
await expect(exportBtn).toBeVisible({ timeout: 5000 });
});
test('renders column toggle button', async ({ page }) => {
const columnToggleBtn = page.locator('[aria-label="Toggle columns"]');
await expect(columnToggleBtn).toBeVisible({ timeout: 5000 });
});
test('column toggle menu shows all columns', async ({ page }) => {
await page.locator('[aria-label="Toggle columns"]').click();
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
// Should show checkboxes for each column
const checkboxes = page.locator('.mantine-Menu-dropdown input[type="checkbox"]');
const count = await checkboxes.count();
expect(count).toBeGreaterThanOrEqual(8);
});
test('toggling column visibility hides a column', async ({ page }) => {
// Verify "Email" header is initially visible
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible({ timeout: 5000 });
// Open column toggle menu
await page.locator('[aria-label="Toggle columns"]').click();
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
// Uncheck the "Email" column
const emailCheckbox = page.locator('.mantine-Checkbox-root', { hasText: 'Email' }).locator('input[type="checkbox"]');
await emailCheckbox.click();
await page.waitForTimeout(300);
// Close the menu by clicking elsewhere
await page.locator('[role="grid"]').click();
await page.waitForTimeout(300);
// Email header should now be hidden
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).not.toBeVisible();
});
});
// ─── 11. Column Pinning ─────────────────────────────────────────────────────────
test.describe('Column Pinning', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-column-pinning');
});
test('renders grid with pinned columns', async ({ page }) => {
// ID and First Name should be visible (pinned left)
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
// Active should be visible (pinned right)
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
});
test('all column headers render', async ({ page }) => {
const headers = page.locator('[role="columnheader"]');
const count = await headers.count();
expect(count).toBeGreaterThanOrEqual(8);
});
});
// ─── 12. Header Grouping ────────────────────────────────────────────────────────
test.describe('Header Grouping', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-header-grouping');
});
test('renders group headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'Personal Info' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'Contact' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Employment' })).toBeVisible();
});
test('renders child column headers under groups', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
});
test('has multiple header rows', async ({ page }) => {
// Should have at least 2 header rows (group + column)
const headerRows = page.locator('[role="row"]').filter({ has: page.locator('[role="columnheader"]') });
const count = await headerRows.count();
expect(count).toBeGreaterThanOrEqual(2);
});
});
// ─── 13. Data Grouping ──────────────────────────────────────────────────────────
test.describe('Data Grouping', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-data-grouping');
});
test('renders grouped rows by department', async ({ page }) => {
// Should show department names as group headers
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="row"]', { hasText: 'Marketing' })).toBeVisible();
});
test('expanding a group shows its members', async ({ page }) => {
// Click on Engineering group to expand it
const engineeringRow = page.locator('[role="row"]', { hasText: 'Engineering' });
await engineeringRow.locator('button').first().click();
await page.waitForTimeout(300);
// Should show individual people from Engineering department
// From generateData: index 0 is Engineering (Alice Smith)
const rows = page.locator('[role="row"]');
const count = await rows.count();
// After expanding one group, should have more rows visible
expect(count).toBeGreaterThan(8); // 8 department groups
});
});
// ─── 14. Column Reordering ──────────────────────────────────────────────────────
test.describe('Column Reordering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-column-reordering');
});
test('renders all column headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
});
test('column headers have draggable attribute', async ({ page }) => {
// Column headers should be draggable for reordering
const headers = page.locator('[role="columnheader"][draggable="true"]');
const count = await headers.count();
// At least some headers should be draggable (selection column is excluded)
expect(count).toBeGreaterThan(0);
});
});
// ─── 15. Infinite Scroll ────────────────────────────────────────────────────────
test.describe('Infinite Scroll', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-infinite-scroll');
});
test('renders initial batch of rows', async ({ page }) => {
// Initial batch is 50 rows
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
});
test('scrolling to bottom loads more data', async ({ page }) => {
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
// Scroll to bottom of the grid container
const container = page.locator('[role="grid"] [tabindex="0"]');
await container.click();
// Use End key to jump to last row, triggering infinite scroll
await page.keyboard.press('End');
await page.waitForTimeout(2000); // Wait for the 1000ms load delay + buffer
// Should now have more than 50 rows
await expect(page.locator('text=Current: 100 rows')).toBeVisible({ timeout: 5000 });
});
});
// ─── 16. Text Filtering ─────────────────────────────────────────────────────────
test.describe('Text Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-text-filtering');
});
test('filterable columns show filter icon', async ({ page }) => {
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
await expect(firstNameHeader).toBeVisible({ timeout: 5000 });
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening filter popover shows text filter UI', async ({ page }) => {
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 5000 });
});
test('non-filterable columns have no filter icon', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]').filter({ hasText: 'ID' });
await expect(idHeader).toBeVisible({ timeout: 5000 });
const filterIcon = idHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).not.toBeVisible();
});
});
// ─── 17. Number Filtering ───────────────────────────────────────────────────────
test.describe('Number Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-number-filtering');
});
test('age column has filter icon', async ({ page }) => {
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
await expect(ageHeader).toBeVisible({ timeout: 5000 });
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('salary column has filter icon', async ({ page }) => {
const salaryHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Salary' });
await expect(salaryHeader).toBeVisible({ timeout: 5000 });
const filterIcon = salaryHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening number filter shows numeric filter UI', async ({ page }) => {
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: age')).toBeVisible({ timeout: 5000 });
});
});
// ─── 18. Enum Filtering ─────────────────────────────────────────────────────────
test.describe('Enum Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-enum-filtering');
});
test('department column has filter icon', async ({ page }) => {
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
await expect(deptHeader).toBeVisible({ timeout: 5000 });
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening enum filter shows the filter popover', async ({ page }) => {
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 });
});
});
// ─── 19. Boolean Filtering ──────────────────────────────────────────────────────
test.describe('Boolean Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-boolean-filtering');
});
test('active column has filter icon', async ({ page }) => {
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
await expect(activeHeader).toBeVisible({ timeout: 5000 });
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening boolean filter shows the filter popover', async ({ page }) => {
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: active')).toBeVisible({ timeout: 5000 });
});
});
// ─── 20. Date Filtering ─────────────────────────────────────────────────────────
test.describe('Date Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-date-filtering');
});
test('start date column has filter icon', async ({ page }) => {
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
await expect(dateHeader).toBeVisible({ timeout: 5000 });
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening date filter shows the filter popover', async ({ page }) => {
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: startDate')).toBeVisible({ timeout: 5000 });
});
});
// ─── 21. All Filter Types ───────────────────────────────────────────────────────
test.describe('All Filter Types', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-all-filter-types');
});
test('all filterable columns show filter icons', async ({ page }) => {
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
for (const colName of filterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
}
});
test('non-filterable columns have no filter icon', async ({ page }) => {
const nonFilterableColumns = ['ID', 'Email', 'Salary'];
for (const colName of nonFilterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).not.toBeVisible();
}
});
});
// ─── 22. Server-Side Filtering & Sorting ────────────────────────────────────────
test.describe('Server-Side Filtering & Sorting', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'server-side-filtering-sorting');
});
test('renders data from simulated server', async ({ page }) => {
// Wait for server data to load (300ms simulated delay)
await expect(page.locator('text=Loading: false')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('shows server-side mode indicator', async ({ page }) => {
await expect(page.locator('text=Server-Side Mode:')).toBeVisible({ timeout: 5000 });
});
test('sorting updates server state', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
// Wait for initial load
await page.waitForTimeout(500);
// Click First Name header to sort
const firstNameHeader = page.locator('[role="columnheader"]', { hasText: 'First Name' });
await firstNameHeader.click();
await page.waitForTimeout(500);
// Active Sorting section should now show sorting state
await expect(page.locator('text=Active Sorting:')).toBeVisible();
});
});
// ─── 23. Large Dataset with Filtering ───────────────────────────────────────────
test.describe('Large Dataset with Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'large-dataset-with-filtering');
});
test('renders large dataset with filter columns', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('all expected filter columns have filter icons', async ({ page }) => {
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
for (const colName of filterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
}
});
});

View File

@@ -0,0 +1,333 @@
import { expect, test } from '@playwright/test'
// Helper to navigate to a story inside the Storybook iframe
async function gotoStory(page: any, storyId: string) {
await page.goto(`/iframe.html?id=components-griddy--${storyId}&viewMode=story`)
// Wait for the grid root to render
await page.waitForSelector('[role="grid"]', { timeout: 10000 })
}
// ─── 1. Error Boundary ──────────────────────────────────────────────────────
test.describe('Error Boundary', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-error-boundary')
})
test('should render grid normally before error', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible()
await expect(page.locator('[role="row"]').first()).toBeVisible()
})
test('should show error fallback when error is triggered', async ({ page }) => {
await page.locator('button:has-text("Trigger Error")').click()
await expect(page.locator('text=Something went wrong rendering the grid.')).toBeVisible({ timeout: 5000 })
await expect(page.locator('text=Intentional render error for testing')).toBeVisible()
await expect(page.locator('button:has-text("Retry")')).toBeVisible()
})
test('should recover when retry is clicked', async ({ page }) => {
await page.locator('button:has-text("Trigger Error")').click()
await expect(page.locator('text=Something went wrong rendering the grid.')).toBeVisible({ timeout: 5000 })
await page.locator('button:has-text("Retry")').click()
// The error boundary defers state reset via setTimeout to let parent onRetry flush first
// Wait for the error message to disappear, then verify grid re-renders
await expect(page.locator('text=Something went wrong rendering the grid.')).not.toBeVisible({ timeout: 10000 })
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 })
})
})
// ─── 2. Loading States ──────────────────────────────────────────────────────
test.describe('Loading States', () => {
test('should show skeleton when loading with no data', async ({ page }) => {
// Navigate directly - don't wait for grid role since it may show skeleton first
await page.goto('/iframe.html?id=components-griddy--with-loading-states&viewMode=story')
// Skeleton uses shimmer animation - look for the skeleton bar elements
const skeleton = page.locator('[class*="skeleton"]')
await expect(skeleton.first()).toBeVisible({ timeout: 5000 })
})
test('should show grid after data loads', async ({ page }) => {
await page.goto('/iframe.html?id=components-griddy--with-loading-states&viewMode=story')
// Wait for data to load (3s delay in story)
await expect(page.locator('[role="row"]').first()).toBeVisible({ timeout: 6000 })
})
test('should show skeleton again after reload', async ({ page }) => {
await page.goto('/iframe.html?id=components-griddy--with-loading-states&viewMode=story')
// Wait for initial load
await expect(page.locator('[role="row"]').first()).toBeVisible({ timeout: 6000 })
// Click reload
await page.locator('button:has-text("Reload Data")').click()
// Skeleton should appear
const skeleton = page.locator('[class*="skeleton"]')
await expect(skeleton.first()).toBeVisible({ timeout: 3000 })
// Then data should load again
await expect(page.locator('[role="row"]').first()).toBeVisible({ timeout: 5000 })
})
})
// ─── 3. Custom Cell Renderers ───────────────────────────────────────────────
test.describe('Custom Cell Renderers', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-custom-renderers')
})
test('should render badge elements for department column', async ({ page }) => {
const badges = page.locator('[class*="renderer-badge"]')
await expect(badges.first()).toBeVisible({ timeout: 5000 })
expect(await badges.count()).toBeGreaterThan(0)
})
test('should render progress bar elements', async ({ page }) => {
const progressBars = page.locator('[class*="renderer-progress"]')
await expect(progressBars.first()).toBeVisible({ timeout: 5000 })
expect(await progressBars.count()).toBeGreaterThan(0)
const innerBar = page.locator('[class*="renderer-progress-bar"]').first()
await expect(innerBar).toBeVisible()
})
test('should render sparkline SVGs', async ({ page }) => {
const sparklines = page.locator('[class*="renderer-sparkline"]')
await expect(sparklines.first()).toBeVisible({ timeout: 5000 })
expect(await sparklines.count()).toBeGreaterThan(0)
const polyline = page.locator('[class*="renderer-sparkline"] polyline').first()
await expect(polyline).toBeVisible()
})
})
// ─── 4. Quick Filters ──────────────────────────────────────────────────────
test.describe('Quick Filters', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-quick-filters')
})
test('should show quick filter dropdown in filter popover', async ({ page }) => {
// The Department column has a filter button - click it
const departmentHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' })
await expect(departmentHeader).toBeVisible({ timeout: 5000 })
// Click the filter icon inside the Department header
const filterIcon = departmentHeader.locator('[aria-label="Open column filter"]')
await expect(filterIcon).toBeVisible({ timeout: 3000 })
await filterIcon.click()
// The popover should show the "Filter: department" text from ColumnFilterPopover
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 })
// Quick filter section text should also appear
await expect(page.getByText('Quick Filter', { exact: true })).toBeVisible({ timeout: 3000 })
})
test('should show unique values as checkboxes', async ({ page }) => {
const departmentHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' })
await expect(departmentHeader).toBeVisible({ timeout: 5000 })
// Click the filter icon
const filterIcon = departmentHeader.locator('[aria-label="Open column filter"]')
await filterIcon.click()
// Wait for the filter popover to open
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 })
// The Quick Filter section should have the search values input
await expect(page.locator('input[placeholder="Search values..."]')).toBeVisible({ timeout: 3000 })
// Should have checkbox labels for department values (e.g., Design, Engineering, Finance...)
const checkboxCount = await page.locator('input[type="checkbox"]').count()
expect(checkboxCount).toBeGreaterThan(0)
})
})
// ─── 5. Advanced Search ────────────────────────────────────────────────────
test.describe('Advanced Search', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-advanced-search')
})
test('should render the advanced search panel', async ({ page }) => {
// Look for the panel by its CSS class since "Advanced Search" text also appears in the info box
const panel = page.locator('[class*="advanced-search"]')
await expect(panel.first()).toBeVisible({ timeout: 5000 })
})
test('should have boolean operator selector', async ({ page }) => {
// Use exact text matching for the segmented control labels
const segmented = page.locator('.mantine-SegmentedControl-root')
await expect(segmented).toBeVisible({ timeout: 5000 })
await expect(page.getByText('AND', { exact: true })).toBeVisible()
await expect(page.getByText('OR', { exact: true })).toBeVisible()
await expect(page.getByText('NOT', { exact: true })).toBeVisible()
})
test('should have a condition row with column, operator, value', async ({ page }) => {
await expect(page.locator('input[placeholder="Column"]')).toBeVisible({ timeout: 5000 })
await expect(page.locator('input[placeholder="Value"]')).toBeVisible()
})
test('should add a new condition row when clicking Add', async ({ page }) => {
const addBtn = page.locator('button:has-text("Add condition")')
await expect(addBtn).toBeVisible({ timeout: 5000 })
const initialCount = await page.locator('input[placeholder="Value"]').count()
await addBtn.click()
const newCount = await page.locator('input[placeholder="Value"]').count()
expect(newCount).toBe(initialCount + 1)
})
test('should filter data when search is applied', async ({ page }) => {
const initialRowCount = await page.locator('[role="row"]').count()
await page.locator('input[placeholder="Column"]').click()
await page.locator('[role="option"]:has-text("First Name")').click()
await page.locator('input[placeholder="Value"]').fill('Alice')
await page.locator('button:has-text("Search")').click()
await page.waitForTimeout(500)
const filteredRowCount = await page.locator('[role="row"]').count()
expect(filteredRowCount).toBeLessThan(initialRowCount)
})
test('should clear search when Clear is clicked', async ({ page }) => {
await page.locator('input[placeholder="Column"]').click()
await page.locator('[role="option"]:has-text("First Name")').click()
await page.locator('input[placeholder="Value"]').fill('Alice')
await page.locator('button:has-text("Search")').click()
await page.waitForTimeout(500)
const filteredCount = await page.locator('[role="row"]').count()
await page.locator('[class*="advanced-search"] button:has-text("Clear")').click()
await page.waitForTimeout(500)
const clearedCount = await page.locator('[role="row"]').count()
expect(clearedCount).toBeGreaterThanOrEqual(filteredCount)
})
})
// ─── 6. Filter Presets ─────────────────────────────────────────────────────
test.describe('Filter Presets', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-filter-presets')
// Clear any existing presets from localStorage
await page.evaluate(() => localStorage.removeItem('griddy-filter-presets-storybook-presets'))
})
test('should show filter presets button in toolbar', async ({ page }) => {
const presetsBtn = page.locator('[aria-label="Filter presets"]')
await expect(presetsBtn).toBeVisible({ timeout: 5000 })
})
test('should open presets menu on click', async ({ page }) => {
await page.locator('[aria-label="Filter presets"]').click()
await expect(page.locator('text=Saved Presets')).toBeVisible({ timeout: 3000 })
await expect(page.locator('text=Save Current Filters')).toBeVisible()
})
test('should show empty state when no presets saved', async ({ page }) => {
await page.locator('[aria-label="Filter presets"]').click()
await expect(page.locator('text=No presets saved')).toBeVisible({ timeout: 3000 })
})
test('should save a preset', async ({ page }) => {
await page.locator('[aria-label="Filter presets"]').click()
await expect(page.locator('text=Save Current Filters')).toBeVisible({ timeout: 3000 })
await page.locator('input[placeholder="Preset name"]').fill('My Test Preset')
// Use a more specific selector to only match the actual Save button, not menu items
await page.getByRole('button', { name: 'Save' }).click()
// Reopen menu and check preset is listed
await page.locator('[aria-label="Filter presets"]').click()
await expect(page.locator('text=My Test Preset')).toBeVisible({ timeout: 3000 })
})
})
// ─── 7. Search History ─────────────────────────────────────────────────────
test.describe('Search History', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-search-history')
// Clear any existing search history
await page.evaluate(() => localStorage.removeItem('griddy-search-history-storybook-search-history'))
})
test('should open search overlay with Ctrl+F', async ({ page }) => {
const container = page.locator('[class*="griddy-container"]')
await container.click()
await page.keyboard.press('Control+f')
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
})
test('should filter grid when typing in search', async ({ page }) => {
const container = page.locator('[class*="griddy-container"]')
await container.click()
await page.keyboard.press('Control+f')
const initialRows = await page.locator('[role="row"]').count()
const searchInput = page.locator('[aria-label="Search grid"]')
await searchInput.fill('Alice')
await page.waitForTimeout(500)
const filteredRows = await page.locator('[role="row"]').count()
expect(filteredRows).toBeLessThan(initialRows)
})
test('should close search with Escape', async ({ page }) => {
const container = page.locator('[class*="griddy-container"]')
await container.click()
await page.keyboard.press('Control+f')
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
await page.keyboard.press('Escape')
await expect(page.locator('[class*="search-overlay"]')).not.toBeVisible({ timeout: 3000 })
})
test('should close search with X button', async ({ page }) => {
const container = page.locator('[class*="griddy-container"]')
await container.click()
await page.keyboard.press('Control+f')
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
await page.locator('[aria-label="Close search"]').click()
await expect(page.locator('[class*="search-overlay"]')).not.toBeVisible({ timeout: 3000 })
})
test('should show search history on focus after previous search', async ({ page }) => {
const container = page.locator('[class*="griddy-container"]')
await container.click()
await page.keyboard.press('Control+f')
const searchInput = page.locator('[aria-label="Search grid"]')
await searchInput.fill('Alice')
await page.waitForTimeout(500)
// Close and reopen search
await page.keyboard.press('Escape')
await expect(page.locator('[class*="search-overlay"]')).not.toBeVisible({ timeout: 3000 })
await container.click()
await page.keyboard.press('Control+f')
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
// Focus the input
await searchInput.click()
// History dropdown should appear
await expect(page.locator('text=Recent searches')).toBeVisible({ timeout: 3000 })
await expect(page.locator('[class*="search-history-item"]').first()).toBeVisible()
})
})

View File

@@ -0,0 +1,385 @@
import { expect, test } from '@playwright/test';
// Helper to navigate to a story inside the Storybook iframe
async function gotoStory(page: any, storyId: string) {
await page.goto(`/iframe.html?id=components-griddy--${storyId}&viewMode=story`);
await page.waitForSelector('[role="grid"]', { timeout: 10000 });
}
// Helper to get all visible row text content (from first gridcell in each row)
async function getVisibleRowNames(page: any): Promise<string[]> {
const cells = page.locator('[role="row"] [role="gridcell"]:first-child');
const count = await cells.count();
const names: string[] = [];
for (let i = 0; i < count; i++) {
const text = await cells.nth(i).innerText();
names.push(text.trim());
}
return names;
}
// Helper to click the expand button within a row that contains the given text
async function clickExpandButton(page: any, rowText: string) {
const row = page.locator('[role="row"]', { hasText: rowText });
const expandBtn = row.locator('button').first();
await expandBtn.click();
}
// ─── 1. Tree Nested Mode ──────────────────────────────────────────────────────
test.describe('Tree Nested Mode', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'tree-nested-mode');
});
test('renders root-level rows collapsed by default', async ({ page }) => {
const rows = page.locator('[role="row"]');
// Header row + 3 root rows (Engineering, Design, Sales)
await expect(rows).toHaveCount(4);
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Design' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Sales' })).toBeVisible();
});
test('expanding a root node reveals children', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Backend Team' })).toBeVisible();
});
test('expanding a child node reveals leaf nodes', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
await clickExpandButton(page, 'Frontend Team');
await expect(page.locator('[role="row"]', { hasText: 'Alice Johnson' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Bob Smith' })).toBeVisible();
});
test('collapsing a parent hides all children', async ({ page }) => {
// Expand Engineering
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
// Collapse Engineering
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).not.toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Backend Team' })).not.toBeVisible();
});
test('leaf nodes have no expand button', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
await clickExpandButton(page, 'Frontend Team');
const leafRow = page.locator('[role="row"]', { hasText: 'Alice Johnson' });
await expect(leafRow).toBeVisible();
// Leaf nodes render a <span> instead of <button> for the expand area
const buttons = leafRow.locator('button');
// Should have no expand button (only possible checkbox button, not tree expand)
const spanExpand = leafRow.locator('span[style*="cursor: default"]');
await expect(spanExpand).toBeVisible();
});
test('rows are indented based on depth level', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
await clickExpandButton(page, 'Frontend Team');
// Get the first gridcell of root vs child vs leaf
const rootCell = page
.locator('[role="row"]', { hasText: 'Engineering' })
.locator('[role="gridcell"]')
.first();
const childCell = page
.locator('[role="row"]', { hasText: 'Frontend Team' })
.locator('[role="gridcell"]')
.first();
const leafCell = page
.locator('[role="row"]', { hasText: 'Alice Johnson' })
.locator('[role="gridcell"]')
.first();
const rootPadding = await rootCell.evaluate((el: HTMLElement) =>
parseInt(getComputedStyle(el).paddingLeft, 10)
);
const childPadding = await childCell.evaluate((el: HTMLElement) =>
parseInt(getComputedStyle(el).paddingLeft, 10)
);
const leafPadding = await leafCell.evaluate((el: HTMLElement) =>
parseInt(getComputedStyle(el).paddingLeft, 10)
);
// Each level should be more indented than the previous
expect(childPadding).toBeGreaterThan(rootPadding);
expect(leafPadding).toBeGreaterThan(childPadding);
});
});
// ─── 2. Tree Flat Mode ────────────────────────────────────────────────────────
test.describe('Tree Flat Mode', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'tree-flat-mode');
});
test('renders root nodes from flat data', async ({ page }) => {
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Design' })).toBeVisible();
});
test('expanding shows correctly nested children', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Backend Team' })).toBeVisible();
await clickExpandButton(page, 'Frontend Team');
await expect(page.locator('[role="row"]', { hasText: 'Alice Johnson' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Bob Smith' })).toBeVisible();
});
test('structure matches expected parent-child relationships', async ({ page }) => {
await clickExpandButton(page, 'Design');
await expect(page.locator('[role="row"]', { hasText: 'Product Design' })).toBeVisible();
await clickExpandButton(page, 'Product Design');
await expect(page.locator('[role="row"]', { hasText: 'Frank Miller' })).toBeVisible();
});
});
// ─── 3. Tree Lazy Mode ───────────────────────────────────────────────────────
test.describe('Tree Lazy Mode', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'tree-lazy-mode');
});
test('root nodes render immediately', async ({ page }) => {
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Design' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Sales' })).toBeVisible();
});
test('expanding a node shows loading then children', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
// Wait for lazy-loaded children to appear (800ms delay in story)
await expect(page.locator('[role="row"]', { hasText: 'Engineering - Team A' })).toBeVisible({
timeout: 5000,
});
await expect(page.locator('[role="row"]', { hasText: 'Engineering - Team B' })).toBeVisible();
});
test('lazy-loaded children are expandable', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Engineering - Team A' })).toBeVisible({
timeout: 5000,
});
await clickExpandButton(page, 'Engineering - Team A');
await expect(
page.locator('[role="row"]', { hasText: 'Person 1 (Engineering - Team A)' })
).toBeVisible({ timeout: 5000 });
});
test('re-collapsing and re-expanding uses cached data', async ({ page }) => {
// First expand
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Engineering - Team A' })).toBeVisible({
timeout: 5000,
});
// Collapse
await clickExpandButton(page, 'Engineering');
await expect(
page.locator('[role="row"]', { hasText: 'Engineering - Team A' })
).not.toBeVisible();
// Re-expand — should appear quickly without loading spinner (cached)
await clickExpandButton(page, 'Engineering');
// Children should appear nearly instantly from cache
await expect(page.locator('[role="row"]', { hasText: 'Engineering - Team A' })).toBeVisible({
timeout: 1000,
});
});
});
// ─── 4. Tree with Search Auto-Expand ─────────────────────────────────────────
test.describe('Tree with Search Auto-Expand', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'tree-with-search');
});
test('Ctrl+F opens search overlay', async ({ page }) => {
// Focus the grid scroll container before pressing keyboard shortcut
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
test('searching for a leaf node auto-expands ancestors', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
// Alice Johnson should become visible (ancestors auto-expanded)
await expect(page.locator('[role="row"]', { hasText: 'Alice Johnson' })).toBeVisible({
timeout: 5000,
});
// Parent nodes should also be visible
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
});
test('clearing search preserves expanded state', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
await expect(page.locator('[role="row"]', { hasText: 'Alice Johnson' })).toBeVisible({
timeout: 5000,
});
// Clear the search
await searchInput.fill('');
// Previously expanded nodes should still be visible
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible();
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
});
});
// ─── 5. Tree Custom Icons ─────────────────────────────────────────────────────
test.describe('Tree Custom Icons', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'tree-custom-icons');
});
test('custom expand/collapse icons render', async ({ page }) => {
// Collapsed nodes should show 📁
const collapsedRow = page.locator('[role="row"]', { hasText: 'Engineering' });
await expect(collapsedRow.locator('text=📁')).toBeVisible();
// Expand and check for 📂
await clickExpandButton(page, 'Engineering');
await expect(collapsedRow.locator('text=📂')).toBeVisible();
});
test('leaf nodes show leaf icon', async ({ page }) => {
await clickExpandButton(page, 'Engineering');
await clickExpandButton(page, 'Frontend Team');
const leafRow = page.locator('[role="row"]', { hasText: 'Alice Johnson' });
await expect(leafRow.locator('text=👤')).toBeVisible();
});
});
// ─── 6. Tree Deep with MaxDepth ──────────────────────────────────────────────
test.describe('Tree Deep with MaxDepth', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'tree-deep-with-max-depth');
});
test('tree renders with depth limiting', async ({ page }) => {
await expect(page.locator('[role="row"]', { hasText: 'Level 1 (Root)' })).toBeVisible();
});
test('expanding nodes works up to maxDepth', async ({ page }) => {
await clickExpandButton(page, 'Level 1 (Root)');
await expect(page.locator('[role="row"]', { hasText: 'Level 2' })).toBeVisible();
await clickExpandButton(page, 'Level 2');
await expect(page.locator('[role="row"]', { hasText: 'Level 3' })).toBeVisible();
});
test('nodes beyond maxDepth are not rendered', async ({ page }) => {
await clickExpandButton(page, 'Level 1 (Root)');
await clickExpandButton(page, 'Level 2');
await expect(page.locator('[role="row"]', { hasText: 'Level 3' })).toBeVisible();
// Level 3 is at depth 2 (0-indexed), maxDepth is 3
// Level 3 should either not be expandable or Level 4 should not appear
// Check that Level 4 row doesn't exist after trying to expand Level 3
const level3Row = page.locator('[role="row"]', { hasText: 'Level 3' });
const expandBtn = level3Row.locator('button');
const expandBtnCount = await expandBtn.count();
if (expandBtnCount > 0) {
await expandBtn.first().click();
// Even after clicking, Level 4 should not appear (beyond maxDepth)
}
// Level 5 Item should never be visible regardless
await expect(page.locator('[role="row"]', { hasText: 'Level 5 Item' })).not.toBeVisible();
});
});
// ─── 7. Keyboard Navigation ──────────────────────────────────────────────────
test.describe('Tree Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'tree-nested-mode');
});
test('ArrowRight on collapsed node expands it', async ({ page }) => {
// Click the Engineering row to focus it
await page.locator('[role="row"]', { hasText: 'Engineering' }).click();
await page.keyboard.press('ArrowRight');
// Children should now be visible
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible({
timeout: 3000,
});
});
test('ArrowRight on expanded node moves focus to first child', async ({ page }) => {
// Expand Engineering first
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
// Click Engineering row to focus it, then ArrowRight to move to first child
await page.locator('[role="row"]', { hasText: 'Engineering' }).click();
await page.keyboard.press('ArrowRight');
// Frontend Team row should be focused (check via :focus-within or active state)
// We verify by pressing ArrowRight again which should expand Frontend Team
await page.keyboard.press('ArrowRight');
await expect(page.locator('[role="row"]', { hasText: 'Alice Johnson' })).toBeVisible({
timeout: 3000,
});
});
test('ArrowLeft on expanded node collapses it', async ({ page }) => {
// Expand Engineering
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
// Click Engineering row, then ArrowLeft to collapse
await page.locator('[role="row"]', { hasText: 'Engineering' }).click();
await page.keyboard.press('ArrowLeft');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).not.toBeVisible({
timeout: 3000,
});
});
test('ArrowLeft on child node moves focus to parent', async ({ page }) => {
// Expand Engineering
await clickExpandButton(page, 'Engineering');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).toBeVisible();
// Focus the child row
await page.locator('[role="row"]', { hasText: 'Frontend Team' }).click();
// ArrowLeft on a collapsed child should move focus to parent
await page.keyboard.press('ArrowLeft');
// Verify parent is focused by pressing ArrowLeft again (should collapse Engineering)
await page.keyboard.press('ArrowLeft');
await expect(page.locator('[role="row"]', { hasText: 'Frontend Team' })).not.toBeVisible({
timeout: 3000,
});
});
});