Latest changes

This commit is contained in:
Hein
2026-02-16 22:48:48 +02:00
parent 391450f615
commit 78468455eb
15 changed files with 1713 additions and 214 deletions

View File

@@ -1,13 +1,20 @@
# Griddy - Feature Complete Implementation Plan
## Project Overview
Griddy is a native TypeScript HTML table/grid component designed as a lightweight, extensible alternative to both Glide Data Editor and Mantine React Table. It is built on **TanStack Table** (headless table model for sorting, filtering, pagination, grouping, selection) and **TanStack Virtual** (row virtualization for rendering performance), with a Zustand store for application-level state.
## Read these
Always have a look in llm/docs folder for info about the tools.
Refer to your last context and update it. src/Griddy/CONTEXT.md
---
## Architecture & Core Design Principles
### 1. Core Philosophy
- **TanStack Table as the Table Model**: All table logic (sorting, filtering, column ordering, pagination, row selection, column visibility, grouping) is managed by `@tanstack/react-table`. Griddy's column definitions map to TanStack `ColumnDef<T>` under the hood.
- **TanStack Virtual for Rendering**: `@tanstack/react-virtual` virtualizes the visible row window. The virtualizer receives the row count from TanStack Table's row model and renders only what's on screen.
- **Zustand for Application State**: Grid-level concerns not owned by TanStack Table (active cell position, edit mode, search overlay visibility, focused row index, keyboard mode) live in a Zustand store.
@@ -16,7 +23,9 @@ Griddy is a native TypeScript HTML table/grid component designed as a lightweigh
- **Plugin-Based**: Advanced features are opt-in through configuration, not baked into the core rendering path.
### 1.5. Lessons from Gridler
Gridler (existing implementation) provides valuable patterns:
- **Zustand Store Pattern**: Central state management using `createSyncStore` for reactive updates
- **Data Adapter Pattern**: Wrapper components that interface with store (LocalDataAdaptor, FormAdaptor, APIAdaptor)
- **Event System**: Uses CustomEvent for inter-component communication
@@ -79,6 +88,7 @@ Gridler (existing implementation) provides valuable patterns:
```
**Key integration points**:
1. `useReactTable()` produces the full sorted/filtered/grouped row model
2. `useVirtualizer()` receives `table.getRowModel().rows.length` as its `count`
3. The render loop maps virtual items back to TanStack Table rows by index
@@ -195,6 +205,7 @@ Griddy/
TanStack Table is the **headless table engine** that manages all table state and logic. Griddy wraps it with opinionated defaults and a simpler column API.
**Column Definition Mapping**:
```typescript
// Griddy's user-facing column API
interface GriddyColumn<T> {
@@ -243,6 +254,7 @@ function mapColumns<T>(columns: GriddyColumn<T>[]): ColumnDef<T>[] {
```
**Table Instance Setup** (in `GriddyTable.tsx` / `useGriddy.ts`):
```typescript
const table = useReactTable<T>({
data,
@@ -282,7 +294,7 @@ const table = useReactTable<T>({
getPaginationRowModel: paginationConfig?.enabled ? getPaginationRowModel() : undefined,
getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined,
getExpandedRowModel: getExpandedRowModel(),
})
});
```
#### 2. Virtualization (TanStack Virtual)
@@ -291,21 +303,21 @@ TanStack Virtual renders only visible rows from the TanStack Table row model.
```typescript
// In useGridVirtualizer.ts
const rowModel = table.getRowModel()
const rowModel = table.getRowModel();
const virtualizer = useVirtualizer({
count: rowModel.rows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => rowHeight, // configurable, default 36px
overscan: overscanCount, // configurable, default 10
})
estimateSize: () => rowHeight, // configurable, default 36px
overscan: overscanCount, // configurable, default 10
});
// Render loop maps virtual items → table rows
const virtualRows = virtualizer.getVirtualItems()
virtualRows.map(virtualRow => {
const row = rowModel.rows[virtualRow.index]
const virtualRows = virtualizer.getVirtualItems();
virtualRows.map((virtualRow) => {
const row = rowModel.rows[virtualRow.index];
// render row.getVisibleCells() via flexRender
})
});
```
- **Row Virtualization**: Only visible rows rendered, powered by TanStack Virtual
@@ -315,6 +327,7 @@ virtualRows.map(virtualRow => {
- **Column Virtualization**: Deferred (not needed initially)
#### 3. Data Handling
- **Local Data**: Direct array-based data binding passed to TanStack Table
- **Remote Server Data**: Async data fetching with loading states
- **Cursor-Based Paging**: Server-side paging with cursor tokens
@@ -323,49 +336,52 @@ virtualRows.map(virtualRow => {
- **Data Adapters**: Pluggable adapters for different data sources
API:
```typescript
interface GriddyDataSource<T> {
data: T[]
total?: number
pageInfo?: { hasNextPage: boolean, cursor?: string }
isLoading?: boolean
error?: Error
data: T[];
total?: number;
pageInfo?: { hasNextPage: boolean; cursor?: string };
isLoading?: boolean;
error?: Error;
}
interface GriddyProps<T> {
data: T[]
columns: GriddyColumn<T>[]
getRowId?: (row: T) => string // for stable row identity
onDataChange?: (data: T[]) => void
dataAdapter?: DataAdapter<T>
data: T[];
columns: GriddyColumn<T>[];
getRowId?: (row: T) => string; // for stable row identity
onDataChange?: (data: T[]) => void;
dataAdapter?: DataAdapter<T>;
// Keyboard
keyboardNavigation?: boolean // default: true
keyboardNavigation?: boolean; // default: true
// Selection
selection?: SelectionConfig
onRowSelectionChange?: (selection: Record<string, boolean>) => void
selection?: SelectionConfig;
onRowSelectionChange?: (selection: Record<string, boolean>) => void;
// Sorting
sorting?: SortingState
onSortingChange?: (sorting: SortingState) => void
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
// Filtering
columnFilters?: ColumnFiltersState
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
columnFilters?: ColumnFiltersState;
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
// Search
search?: SearchConfig
search?: SearchConfig;
// Editing
onEditCommit?: (rowId: string, columnId: string, value: any) => void | Promise<void>
onEditCommit?: (rowId: string, columnId: string, value: any) => void | Promise<void>;
// Pagination
pagination?: PaginationConfig
pagination?: PaginationConfig;
// Virtualization
rowHeight?: number // default: 36
overscan?: number // default: 10
height?: number | string // container height
rowHeight?: number; // default: 36
overscan?: number; // default: 10
height?: number | string; // container height
// Persistence
persistenceKey?: string // localStorage key prefix
persistenceKey?: string; // localStorage key prefix
}
```
#### 4. Column Management
Powered by TanStack Table's column APIs:
- **Column Definition**: GriddyColumn<T> mapped to TanStack ColumnDef<T>
- **Header Grouping**: TanStack Table `getHeaderGroups()` for multi-level headers
- **Column Pinning**: TanStack Table `columnPinning` state
@@ -375,7 +391,9 @@ Powered by TanStack Table's column APIs:
- **Header Customization**: Custom header via `header` field in column definition
#### 5. Filtering
Powered by TanStack Table's filtering pipeline:
- **Column Filtering**: `enableColumnFilter` per column, `getFilteredRowModel()`
- **Filter Modes**: Built-in TanStack filter functions + custom `filterFn` per column
- **Multi-Filter**: Multiple column filters applied simultaneously (AND logic by default)
@@ -383,7 +401,9 @@ Powered by TanStack Table's filtering pipeline:
- **Custom Filters**: User-provided `filterFn` on column definition
#### 6. Search
Global search powered by TanStack Table's `globalFilter`:
- **Global Search**: `setGlobalFilter()` searches across all columns with `searchable: true`
- **Search Overlay**: Ctrl+F opens search overlay UI (custom, not browser find)
- **Search Highlighting**: Custom cell renderer highlights matching text
@@ -391,19 +411,22 @@ Global search powered by TanStack Table's `globalFilter`:
- **Fuzzy Search**: Optional via custom global filter function
API:
```typescript
interface SearchConfig {
enabled: boolean
debounceMs?: number // default: 300
fuzzy?: boolean
highlightMatches?: boolean // default: true
caseSensitive?: boolean // default: false
placeholder?: string
enabled: boolean;
debounceMs?: number; // default: 300
fuzzy?: boolean;
highlightMatches?: boolean; // default: true
caseSensitive?: boolean; // default: false
placeholder?: string;
}
```
#### 7. Sorting
Powered by TanStack Table's sorting pipeline:
- **Single Column Sort**: Default mode (click header to cycle asc → desc → none)
- **Multi-Column Sort**: Shift+Click adds to sort stack
- **Custom Sort Functions**: `sortFn` on column definition → TanStack `sortingFn`
@@ -417,25 +440,26 @@ Powered by TanStack Table's sorting pipeline:
Griddy is a **keyboard-first** grid. The grid container is focusable (`tabIndex={0}`) and maintains a **focused row index** in the Zustand store. All keyboard shortcuts work when the grid has focus.
#### Keyboard State (Zustand Store)
```typescript
interface GriddyUIState {
// Focus
focusedRowIndex: number | null // index into table.getRowModel().rows
focusedColumnId: string | null // for future cell-level focus
focusedRowIndex: number | null; // index into table.getRowModel().rows
focusedColumnId: string | null; // for future cell-level focus
// Modes
isEditing: boolean // true when a cell editor is open
isSearchOpen: boolean // true when Ctrl+F search overlay is shown
isSelecting: boolean // true when in selection mode (Ctrl+S)
isEditing: boolean; // true when a cell editor is open
isSearchOpen: boolean; // true when Ctrl+F search overlay is shown
isSelecting: boolean; // true when in selection mode (Ctrl+S)
// Actions
setFocusedRow: (index: number | null) => void
setEditing: (editing: boolean) => void
setSearchOpen: (open: boolean) => void
setSelecting: (selecting: boolean) => void
moveFocus: (direction: 'up' | 'down', amount: number) => void
moveFocusToStart: () => void
moveFocusToEnd: () => void
setFocusedRow: (index: number | null) => void;
setEditing: (editing: boolean) => void;
setSearchOpen: (open: boolean) => void;
setSelecting: (selecting: boolean) => void;
moveFocus: (direction: 'up' | 'down', amount: number) => void;
moveFocusToStart: () => void;
moveFocusToEnd: () => void;
}
```
@@ -443,27 +467,27 @@ interface GriddyUIState {
All key bindings are handled in `useKeyboardNavigation.ts` which attaches a `keydown` listener to the grid container. When in **edit mode** or **search mode**, most bindings are suppressed (only Escape is active to exit the mode).
| Key | Action | Context |
|-----|--------|---------|
| `ArrowUp` | Move focus to previous row | Normal mode |
| `ArrowDown` | Move focus to next row | Normal mode |
| `PageUp` | Move focus up by one page (visible row count) | Normal mode |
| `PageDown` | Move focus down by one page (visible row count) | Normal mode |
| `Home` | Move focus to first row | Normal mode |
| `End` | Move focus to last row | Normal mode |
| `Ctrl+F` | Open search overlay (preventDefault to block browser find) | Normal mode |
| `Escape` | Close search overlay / cancel edit / exit selection mode | Any mode |
| `Ctrl+E` | Enter edit mode on focused row's first editable cell | Normal mode |
| `Enter` | Enter edit mode on focused row's first editable cell | Normal mode |
| `Ctrl+S` | Toggle selection mode (preventDefault to block browser save) | Normal mode |
| `Space` | Toggle selection of focused row | Selection mode or normal mode with selection enabled |
| `Shift+ArrowUp` | Extend selection to include previous row | Selection mode (multi-select) |
| `Shift+ArrowDown` | Extend selection to include next row | Selection mode (multi-select) |
| `Ctrl+A` | Select all rows (when multi-select enabled) | Normal mode |
| `Tab` | Move to next editable cell | Edit mode |
| `Shift+Tab` | Move to previous editable cell | Edit mode |
| `Enter` | Commit edit and move to next row | Edit mode |
| `Escape` | Cancel edit, return to normal mode | Edit mode |
| Key | Action | Context |
| ----------------- | ------------------------------------------------------------ | ---------------------------------------------------- |
| `ArrowUp` | Move focus to previous row | Normal mode |
| `ArrowDown` | Move focus to next row | Normal mode |
| `PageUp` | Move focus up by one page (visible row count) | Normal mode |
| `PageDown` | Move focus down by one page (visible row count) | Normal mode |
| `Home` | Move focus to first row | Normal mode |
| `End` | Move focus to last row | Normal mode |
| `Ctrl+F` | Open search overlay (preventDefault to block browser find) | Normal mode |
| `Escape` | Close search overlay / cancel edit / exit selection mode | Any mode |
| `Ctrl+E` | Enter edit mode on focused row's first editable cell | Normal mode |
| `Enter` | Enter edit mode on focused row's first editable cell | Normal mode |
| `Ctrl+S` | Toggle selection mode (preventDefault to block browser save) | Normal mode |
| `Space` | Toggle selection of focused row | Selection mode or normal mode with selection enabled |
| `Shift+ArrowUp` | Extend selection to include previous row | Selection mode (multi-select) |
| `Shift+ArrowDown` | Extend selection to include next row | Selection mode (multi-select) |
| `Ctrl+A` | Select all rows (when multi-select enabled) | Normal mode |
| `Tab` | Move to next editable cell | Edit mode |
| `Shift+Tab` | Move to previous editable cell | Edit mode |
| `Enter` | Commit edit and move to next row | Edit mode |
| `Escape` | Cancel edit, return to normal mode | Edit mode |
#### Implementation: `useKeyboardNavigation.ts`
@@ -473,140 +497,144 @@ function useKeyboardNavigation(
virtualizer: Virtualizer<HTMLDivElement, Element>,
store: GriddyUIState,
config: {
selectionMode: SelectionConfig['mode']
multiSelect: boolean
editingEnabled: boolean
searchEnabled: boolean
selectionMode: SelectionConfig['mode'];
multiSelect: boolean;
editingEnabled: boolean;
searchEnabled: boolean;
}
) {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const { focusedRowIndex, isEditing, isSearchOpen } = store.getState()
const rowCount = table.getRowModel().rows.length
const pageSize = virtualizer.getVirtualItems().length
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const { focusedRowIndex, isEditing, isSearchOpen } = store.getState();
const rowCount = table.getRowModel().rows.length;
const pageSize = virtualizer.getVirtualItems().length;
// Search mode: only Escape exits
if (isSearchOpen) {
if (e.key === 'Escape') {
store.getState().setSearchOpen(false)
e.preventDefault()
// Search mode: only Escape exits
if (isSearchOpen) {
if (e.key === 'Escape') {
store.getState().setSearchOpen(false);
e.preventDefault();
}
return; // let SearchOverlay handle its own keys
}
return // let SearchOverlay handle its own keys
}
// Edit mode: Tab, Shift+Tab, Enter, Escape
if (isEditing) {
if (e.key === 'Escape') {
store.getState().setEditing(false)
e.preventDefault()
// Edit mode: Tab, Shift+Tab, Enter, Escape
if (isEditing) {
if (e.key === 'Escape') {
store.getState().setEditing(false);
e.preventDefault();
}
return; // let editor handle its own keys
}
return // let editor handle its own keys
}
// Normal mode
switch (true) {
case e.key === 'ArrowDown':
e.preventDefault()
store.getState().moveFocus('down', 1)
break
// Normal mode
switch (true) {
case e.key === 'ArrowDown':
e.preventDefault();
store.getState().moveFocus('down', 1);
break;
case e.key === 'ArrowUp':
e.preventDefault()
store.getState().moveFocus('up', 1)
break
case e.key === 'ArrowUp':
e.preventDefault();
store.getState().moveFocus('up', 1);
break;
case e.key === 'PageDown':
e.preventDefault()
store.getState().moveFocus('down', pageSize)
break
case e.key === 'PageDown':
e.preventDefault();
store.getState().moveFocus('down', pageSize);
break;
case e.key === 'PageUp':
e.preventDefault()
store.getState().moveFocus('up', pageSize)
break
case e.key === 'PageUp':
e.preventDefault();
store.getState().moveFocus('up', pageSize);
break;
case e.key === 'Home':
e.preventDefault()
store.getState().moveFocusToStart()
break
case e.key === 'Home':
e.preventDefault();
store.getState().moveFocusToStart();
break;
case e.key === 'End':
e.preventDefault()
store.getState().moveFocusToEnd()
break
case e.key === 'End':
e.preventDefault();
store.getState().moveFocusToEnd();
break;
case e.key === 'f' && e.ctrlKey:
if (config.searchEnabled) {
e.preventDefault() // block browser find
store.getState().setSearchOpen(true)
}
break
case e.key === 'f' && e.ctrlKey:
if (config.searchEnabled) {
e.preventDefault(); // block browser find
store.getState().setSearchOpen(true);
}
break;
case (e.key === 'e' && e.ctrlKey) || e.key === 'Enter':
if (config.editingEnabled && focusedRowIndex !== null) {
e.preventDefault()
store.getState().setEditing(true)
}
break
case (e.key === 'e' && e.ctrlKey) || e.key === 'Enter':
if (config.editingEnabled && focusedRowIndex !== null) {
e.preventDefault();
store.getState().setEditing(true);
}
break;
case e.key === 's' && e.ctrlKey:
if (config.selectionMode !== 'none') {
e.preventDefault() // block browser save
store.getState().setSelecting(!store.getState().isSelecting)
}
break
case e.key === 's' && e.ctrlKey:
if (config.selectionMode !== 'none') {
e.preventDefault(); // block browser save
store.getState().setSelecting(!store.getState().isSelecting);
}
break;
case e.key === ' ':
if (config.selectionMode !== 'none' && focusedRowIndex !== null) {
e.preventDefault()
const row = table.getRowModel().rows[focusedRowIndex]
row.toggleSelected()
}
break
case e.key === ' ':
if (config.selectionMode !== 'none' && focusedRowIndex !== null) {
e.preventDefault();
const row = table.getRowModel().rows[focusedRowIndex];
row.toggleSelected();
}
break;
case e.key === 'a' && e.ctrlKey:
if (config.multiSelect) {
e.preventDefault()
table.toggleAllRowsSelected()
}
break
case e.key === 'a' && e.ctrlKey:
if (config.multiSelect) {
e.preventDefault();
table.toggleAllRowsSelected();
}
break;
case e.key === 'ArrowDown' && e.shiftKey:
if (config.multiSelect && focusedRowIndex !== null) {
e.preventDefault()
const nextIdx = Math.min(focusedRowIndex + 1, rowCount - 1)
const row = table.getRowModel().rows[nextIdx]
row.toggleSelected(true) // select (don't deselect)
store.getState().moveFocus('down', 1)
}
break
case e.key === 'ArrowDown' && e.shiftKey:
if (config.multiSelect && focusedRowIndex !== null) {
e.preventDefault();
const nextIdx = Math.min(focusedRowIndex + 1, rowCount - 1);
const row = table.getRowModel().rows[nextIdx];
row.toggleSelected(true); // select (don't deselect)
store.getState().moveFocus('down', 1);
}
break;
case e.key === 'ArrowUp' && e.shiftKey:
if (config.multiSelect && focusedRowIndex !== null) {
e.preventDefault()
const prevIdx = Math.max(focusedRowIndex - 1, 0)
const row = table.getRowModel().rows[prevIdx]
row.toggleSelected(true)
store.getState().moveFocus('up', 1)
}
break
}
case e.key === 'ArrowUp' && e.shiftKey:
if (config.multiSelect && focusedRowIndex !== null) {
e.preventDefault();
const prevIdx = Math.max(focusedRowIndex - 1, 0);
const row = table.getRowModel().rows[prevIdx];
row.toggleSelected(true);
store.getState().moveFocus('up', 1);
}
break;
}
// Auto-scroll focused row into view
const newFocused = store.getState().focusedRowIndex
if (newFocused !== null) {
virtualizer.scrollToIndex(newFocused, { align: 'auto' })
}
}, [table, virtualizer, store, config])
// Auto-scroll focused row into view
const newFocused = store.getState().focusedRowIndex;
if (newFocused !== null) {
virtualizer.scrollToIndex(newFocused, { align: 'auto' });
}
},
[table, virtualizer, store, config]
);
useEffect(() => {
const el = scrollContainerRef.current
el?.addEventListener('keydown', handleKeyDown)
return () => el?.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
const el = scrollContainerRef.current;
el?.addEventListener('keydown', handleKeyDown);
return () => el?.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
```
#### Focus Visual Indicator
The focused row receives a CSS class `griddy-row--focused` which renders a visible focus ring/highlight. This is distinct from selection highlighting.
```css
@@ -627,6 +655,7 @@ The focused row receives a CSS class `griddy-row--focused` which renders a visib
```
#### Auto-Scroll on Focus Change
When the focused row changes (via keyboard), `virtualizer.scrollToIndex()` ensures the focused row is visible. The `align: 'auto'` option scrolls only if the row is outside the visible area.
---
@@ -640,29 +669,31 @@ Row selection is powered by **TanStack Table's row selection** (`enableRowSelect
```typescript
interface SelectionConfig {
/** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */
mode: 'none' | 'single' | 'multi'
mode: 'none' | 'single' | 'multi';
/** Show checkbox column (auto-added as first column) */
showCheckbox?: boolean // default: true when mode !== 'none'
showCheckbox?: boolean; // default: true when mode !== 'none'
/** Allow clicking row body to toggle selection */
selectOnClick?: boolean // default: true
selectOnClick?: boolean; // default: true
/** Maintain selection across pagination/sorting */
preserveSelection?: boolean // default: true
preserveSelection?: boolean; // default: true
/** Callback when selection changes */
onSelectionChange?: (selectedRows: T[], selectionState: Record<string, boolean>) => void
onSelectionChange?: (selectedRows: T[], selectionState: Record<string, boolean>) => void;
}
```
#### Single Selection Mode (`mode: 'single'`)
- Only one row selected at a time
- Clicking a row selects it and deselects the previous
- Arrow keys move focus; Space selects the focused row (deselects previous)
- TanStack Table config: `enableRowSelection: true`, `enableMultiRowSelection: false`
#### Multi Selection Mode (`mode: 'multi'`)
- Multiple rows can be selected simultaneously
- Click toggles individual row selection
- Shift+Click selects range from last selected to clicked row
@@ -675,6 +706,7 @@ interface SelectionConfig {
- TanStack Table config: `enableRowSelection: true`, `enableMultiRowSelection: true`
#### Checkbox Column
When `showCheckbox` is true (default for selection modes), a checkbox column is automatically prepended:
```typescript
@@ -706,6 +738,7 @@ const checkboxColumn: ColumnDef<T> = {
```
#### Selection State
Selection state uses TanStack Table's `rowSelection` state (a `Record<string, boolean>` keyed by row ID). This integrates automatically with sorting, filtering, and pagination.
```typescript
@@ -727,6 +760,7 @@ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
### In-Place Editing
#### Edit Mode Activation
- `Ctrl+E` or `Enter` on a focused row opens the first editable cell for editing
- Double-click on a cell opens it for editing
- When editing:
@@ -736,16 +770,17 @@ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
- `Escape` cancels the edit
#### Editor Components
```typescript
interface EditorProps<T> {
value: any
column: GriddyColumn<T>
row: T
rowIndex: number
onCommit: (newValue: any) => void
onCancel: () => void
onMoveNext: () => void // Tab
onMovePrev: () => void // Shift+Tab
value: any;
column: GriddyColumn<T>;
row: T;
rowIndex: number;
onCommit: (newValue: any) => void;
onCancel: () => void;
onMoveNext: () => void; // Tab
onMovePrev: () => void; // Shift+Tab
}
```
@@ -756,6 +791,7 @@ Built-in editors: TextEditor, NumericEditor, DateEditor, SelectEditor, CheckboxE
### Search Overlay
#### Behavior
- `Ctrl+F` opens a search overlay bar at the top of the grid
- Search input is auto-focused
- Typing updates `table.setGlobalFilter()` with debounce
@@ -765,6 +801,7 @@ Built-in editors: TextEditor, NumericEditor, DateEditor, SelectEditor, CheckboxE
- `Escape` closes overlay and clears search
#### Implementation
```typescript
// SearchOverlay.tsx
function SearchOverlay({ table, store }: { table: Table<any>, store: GriddyUIState }) {
@@ -801,22 +838,24 @@ function SearchOverlay({ table, store }: { table: Table<any>, store: GriddyUISta
### Pagination
Powered by TanStack Table's pagination:
- **Client-Side**: `getPaginationRowModel()` handles slicing
- **Server-Side**: `manualPagination: true`, data provided per page
- **Cursor-Based**: Adapter handles cursor tokens, data swapped per page
```typescript
interface PaginationConfig {
enabled: boolean
type: 'offset' | 'cursor'
pageSize: number // default: 50
pageSizeOptions?: number[] // default: [25, 50, 100]
onPageChange?: (page: number) => void
onPageSizeChange?: (pageSize: number) => void
enabled: boolean;
type: 'offset' | 'cursor';
pageSize: number; // default: 50
pageSizeOptions?: number[]; // default: [25, 50, 100]
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
}
```
**Keyboard interaction with pagination**:
- `PageUp` / `PageDown` move focus within the current visible window
- When focus reaches the edge of the current page (in paginated mode), it does NOT auto-advance to the next page
- Page navigation is done via the pagination controls or programmatically
@@ -826,6 +865,7 @@ interface PaginationConfig {
### Grouping
Powered by TanStack Table's grouping + expanded row model:
- **Header Grouping**: Multi-level column groups via `getHeaderGroups()`
- **Data Grouping**: `enableGrouping` + `getGroupedRowModel()`
- **Aggregation**: TanStack Table aggregation functions (count, sum, avg, etc.)
@@ -836,6 +876,7 @@ Powered by TanStack Table's grouping + expanded row model:
### Column Pinning
Powered by TanStack Table's column pinning:
```typescript
// TanStack Table state
columnPinning: {
@@ -851,7 +892,9 @@ Rendering uses `table.getLeftHeaderGroups()`, `table.getCenterHeaderGroups()`, `
## Architectural Patterns from Gridler to Adopt
### 1. createSyncStore Pattern (from @warkypublic/zustandsyncstore)
Uses `createSyncStore` which provides a Provider that auto-syncs parent props into the Zustand store, plus a context-scoped `useStore` hook with selector support. `GriddyStoreState` includes both UI state AND synced prop fields (so TypeScript sees them):
```typescript
const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
GriddyStoreState, // UI state + prop fields + internal refs
@@ -879,7 +922,9 @@ const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
```
### 2. Data Adapter Pattern
Adapters feed data into TanStack Table:
```typescript
// LocalDataAdapter: passes array directly to table
// RemoteServerAdapter: fetches data, manages loading state, handles pagination callbacks
@@ -887,28 +932,32 @@ Adapters feed data into TanStack Table:
```
### 3. Event System
CustomEvent for inter-component communication (same as Gridler):
```typescript
state._events.dispatchEvent(new CustomEvent('loadPage', { detail }))
state._events.addEventListener('reload', handler)
state._events.dispatchEvent(new CustomEvent('loadPage', { detail }));
state._events.addEventListener('reload', handler);
```
### 4. Ref-Based Imperative API
```typescript
interface GriddyRef<T> {
getState: () => GriddyUIState
getTable: () => Table<T> // TanStack Table instance
getVirtualizer: () => Virtualizer // TanStack Virtual instance
refresh: () => Promise<void>
scrollToRow: (id: string) => void
selectRow: (id: string) => void
deselectAll: () => void
focusRow: (index: number) => void
startEditing: (rowId: string, columnId?: string) => void
getState: () => GriddyUIState;
getTable: () => Table<T>; // TanStack Table instance
getVirtualizer: () => Virtualizer; // TanStack Virtual instance
refresh: () => Promise<void>;
scrollToRow: (id: string) => void;
selectRow: (id: string) => void;
deselectAll: () => void;
focusRow: (index: number) => void;
startEditing: (rowId: string, columnId?: string) => void;
}
```
### 5. Persistence Layer
```typescript
persist={{
name: `Griddy_${props.persistenceKey}`,
@@ -927,6 +976,7 @@ persist={{
## Implementation Phases
### Phase 1: Core Foundation + TanStack Table
- [ ] Set up Griddy package structure
- [ ] Install `@tanstack/react-table` as dependency
- [ ] Create core types: `GriddyColumn<T>`, `GriddyProps<T>`, `SelectionConfig`, etc.
@@ -941,6 +991,7 @@ persist={{
**Deliverable**: Functional table rendering with TanStack Table powering the data model
### Phase 2: Virtualization + Keyboard Navigation
- [ ] Integrate TanStack Virtual (`useVirtualizer`) with TanStack Table row model
- [ ] Implement `VirtualBody.tsx` with virtual row rendering
- [ ] Implement `TableHeader.tsx` with sticky headers
@@ -957,6 +1008,7 @@ persist={{
**Deliverable**: High-performance virtualized table with full keyboard navigation
### Phase 3: Row Selection
- [ ] Implement single selection mode via TanStack Table `enableRowSelection`
- [ ] Implement multi selection mode via TanStack Table `enableMultiRowSelection`
- [ ] Implement `SelectionCheckbox.tsx` (auto-prepended column)
@@ -973,6 +1025,7 @@ persist={{
**Deliverable**: Full row selection with single and multi modes, keyboard support
### Phase 4: Search
- [ ] Implement `SearchOverlay.tsx` (Ctrl+F activated)
- [ ] Wire global filter to TanStack Table `setGlobalFilter()`
- [ ] Implement search highlighting in cell renderer
@@ -983,6 +1036,7 @@ persist={{
**Deliverable**: Global search with keyboard-activated overlay
### Phase 5: Sorting & Filtering
- [x] Sorting via TanStack Table (click header, Shift+Click for multi)
- [x] Sort indicators in headers
- [x] Column filtering UI (right-click context menu for sort/filter options)
@@ -999,6 +1053,7 @@ persist={{
**Deliverable**: Complete data manipulation features powered by TanStack Table
**Files Created** (9 components):
- `src/Griddy/features/filtering/types.ts` — Filter type system
- `src/Griddy/features/filtering/operators.ts` — Operator definitions for all 4 types
- `src/Griddy/features/filtering/filterFunctions.ts` — TanStack FilterFn implementations
@@ -1010,6 +1065,7 @@ persist={{
- `src/Griddy/features/filtering/ColumnFilterContextMenu.tsx` — Right-click context menu
**Files Modified**:
- `src/Griddy/rendering/TableHeader.tsx` — Integrated context menu + filter popover
- `src/Griddy/core/columnMapper.ts` — Set default filterFn for filterable columns
- `src/Griddy/core/types.ts` — Added FilterConfig to GriddyColumn
@@ -1018,10 +1074,12 @@ persist={{
- `src/Griddy/Griddy.stories.tsx` — Added 6 filtering examples
**Tests**:
- `playwright.config.ts` — Playwright configuration
- `tests/e2e/filtering-context-menu.spec.ts` — 8 comprehensive E2E test cases
### Phase 6: In-Place Editing
- [x] Implement `EditableCell.tsx` with editor mounting
- [x] Implement built-in editors: Text, Numeric, Date, Select, Checkbox
- [x] Keyboard editing:
@@ -1039,6 +1097,7 @@ persist={{
**Deliverable**: Full in-place editing with keyboard support - COMPLETE ✅
### Phase 7: Pagination & Data Adapters
- [x] Client-side pagination via TanStack Table `getPaginationRowModel()`
- [x] Pagination controls UI (page nav, page size selector)
- [x] Server-side pagination callbacks (`onPageChange`, `onPageSizeChange`)
@@ -1052,6 +1111,7 @@ persist={{
**Deliverable**: Pagination and remote data support - COMPLETE ✅
### Phase 8: Advanced Features
- [x] Column hiding/visibility (TanStack `columnVisibility`) - COMPLETE
- [x] Export to CSV - COMPLETE
- [x] Toolbar component (column visibility + export) - COMPLETE
@@ -1063,6 +1123,7 @@ persist={{
**Deliverable**: Advanced table features - PARTIAL ✅ (core features complete)
### Phase 9: Polish & Documentation
- [x] Comprehensive Storybook stories (15+ stories covering all features)
- [x] API documentation (README.md with full API reference)
- [x] TypeScript definitions and examples (EXAMPLES.md)
@@ -1091,16 +1152,19 @@ persist={{
## Dependencies
**Peer Dependencies**:
- react >= 19.0.0
- react-dom >= 19.0.0
- @tanstack/react-table >= 8.0.0
- @tanstack/react-virtual >= 3.13.0
**Optional Peer Dependencies**:
- @mantine/core (for integrated theming)
- @tanstack/react-query (for server-side data)
**Dev Dependencies**:
- Same as Oranguru project (Vitest, Storybook, etc.)
---
@@ -1198,6 +1262,7 @@ The grid follows WAI-ARIA grid pattern:
## Phase 10: Future Enhancements
### Data & State Management
- [ ] **Column layout persistence** - Save/restore column order, widths, visibility to localStorage
- [ ] **Sort/filter state persistence** - Persist column filters and sorting state
- [ ] **Undo/redo for edits** - Ctrl+Z/Ctrl+Y for edit history with state snapshots
@@ -1206,31 +1271,42 @@ The grid follows WAI-ARIA grid pattern:
- [x] **Loading states UI** - Skeleton rows with shimmer + translucent overlay spinner (isLoading prop) ✅
### Advanced Data Features
- [ ] **Tree/hierarchical data** - Parent-child rows with expand/collapse (nested data structures)
- [ ] **Master-detail rows** - Expandable detail panels per row with custom content
- [ ] **Bulk operations** - Multi-row edit, bulk delete with confirmation
- [ ] **Smart column types** - Auto-detect date, number, email columns from data
- [ ] **Copy/paste support** - Clipboard integration (Ctrl+C/Ctrl+V) for cells and rows
### Tree/hierarchical data
- [x] **Tree Structure Column** - Parent-child rows with expand/collapse (nested data structures) ✅
- [x] **On Demand Expand** - Lazy loading with getChildren callback ✅
- [x] **On Search Callback** - Auto-expand parent nodes when search matches children ✅
- [x] **Adaptor Integration** - Lazy tree expansion integrated with data transformations ✅
### Editing Enhancements
- [ ] **Validation system** - Validate edits before commit (min/max, regex, custom validators)
- [ ] **Tab-to-next-editable-cell** - Navigate between editable cells with Tab key
- [ ] **Inline validation feedback** - Show validation errors in edit mode
- [x] **Custom cell renderers** - ProgressBar, Badge, Image, Sparkline renderers via `renderer` + `rendererMeta`
### Filtering & Search
- [x] **Quick filters** - Checkbox list of unique values in filter popover (`filterConfig.quickFilter: true`) ✅
- [x] **Advanced search** - Multi-condition search with AND/OR/NOT operators (AdvancedSearchPanel) ✅
- [x] **Filter presets** - Save/load/delete named filter presets to localStorage (FilterPresetsMenu) ✅
- [x] **Search history** - Recent searches dropdown with localStorage persistence (SearchHistoryDropdown) ✅
### Export & Import
- [ ] **Export to CSV/Excel** - Download current view with filters/sorts applied (load all data)
- [ ] **Export selected rows** - Export only selected rows
- [ ] **Import from CSV** - Bulk data import with validation
- [ ] **PDF export** - Generate PDF reports from grid data
### UI/UX Improvements
- [ ] **Context menu enhancements** - Right-click menu for pin/hide/group/freeze operations
- [ ] **Keyboard shortcuts help** - Modal overlay showing available shortcuts (Ctrl+?)
- [ ] **Column auto-sizing** - Double-click resize handle to fit content
@@ -1239,6 +1315,7 @@ The grid follows WAI-ARIA grid pattern:
- [ ] **Theme presets** - Built-in light/dark/high-contrast themes
### Performance & Optimization
- [ ] **Column virtualization** - Horizontal virtualization for 100+ columns
- [ ] **Row virtualization improvements** - Variable row heights, smoother scrolling
- [ ] **Performance benchmarks** - Document render time, memory usage, FPS
@@ -1246,6 +1323,7 @@ The grid follows WAI-ARIA grid pattern:
- [ ] **Web Worker support** - Offload sorting/filtering to background thread
### Accessibility & Testing
- [ ] **Accessibility improvements** - Enhanced ARIA roles, screen reader announcements
- [ ] **Accessibility audit** - WCAG 2.1 AA compliance verification
- [x] **E2E test suite** - 34 Playwright tests: 8 filtering + 26 Phase 10 feature tests, all passing ✅
@@ -1253,6 +1331,7 @@ The grid follows WAI-ARIA grid pattern:
- [ ] **Performance tests** - Automated performance benchmarking
### Developer Experience
- [ ] **Plugin architecture** - Extensibility system for custom features
- [ ] **Custom hooks** - useGriddyTable, useGriddySelection, useGriddyFilters
- [ ] **TypeDoc documentation** - Auto-generated API docs
@@ -1261,6 +1340,7 @@ The grid follows WAI-ARIA grid pattern:
- [ ] **Storybook controls** - Interactive prop controls for all stories
### Advanced Features
- [ ] **Cell-level focus** - Left/right arrow navigation between cells
- [ ] **Row reordering** - Drag-and-drop to reorder rows
- [ ] **Frozen rows** - Pin specific rows at top/bottom
@@ -1275,12 +1355,14 @@ The grid follows WAI-ARIA grid pattern:
## Implementation Priority
**High Priority** (Next):
1. Column layout persistence
2. Validation system for editors
3. Tab-to-next-editable-cell navigation
4. Context menu enhancements
**Medium Priority**:
1. Tree/hierarchical data
2. Master-detail rows
3. Export enhancements (selected rows, Excel format)
@@ -1288,6 +1370,7 @@ The grid follows WAI-ARIA grid pattern:
5. Copy/paste support
**Low Priority** (Nice to have):
1. Mobile/touch support
2. Plugin architecture
3. Undo/redo
@@ -1303,6 +1386,7 @@ The grid follows WAI-ARIA grid pattern:
3. ✅ Phase 8 completion: Column pinning, header grouping, data grouping, column reordering
4. ✅ Phase 10 batch 1 (7 features): Error boundary, loading states, custom renderers, quick filters, advanced search, filter presets, search history
5. ✅ E2E test suite: 34 Playwright tests (all passing)
6. ✅ Tree/Hierarchical Data: Full tree support with nested/flat/lazy modes, keyboard navigation, search auto-expand
## Next Steps