* 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.
52 KiB
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 TanStackColumnDef<T>under the hood. - TanStack Virtual for Rendering:
@tanstack/react-virtualvirtualizes 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.
- Keyboard-First: Full keyboard navigation is a core feature, not an afterthought. All interactions have keyboard equivalents.
- Callback-Driven: Data is read-only from the grid's perspective. Mutations flow through callbacks (
onRowSelectionChange,onEditCommit,onSortingChange, etc.). - 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
createSyncStorefor reactive updates - Data Adapter Pattern: Wrapper components that interface with store (LocalDataAdaptor, FormAdaptor, APIAdaptor)
- Event System: Uses CustomEvent for inter-component communication
- Provider/Context Pattern: Wraps grid in Provider for configuration and state sharing
- Column Definition System: Type-safe, extensible column definitions with custom rendering
- Ref-based API: Forward refs for imperative commands (refresh, reload, scrollTo, selectRow)
- Persistence: Automatic localStorage persistence for column order and sizing
Griddy will adopt these proven patterns while building on TanStack Table + TanStack Virtual instead of Glide Data Grid
2. How TanStack Table and TanStack Virtual Work Together
┌─────────────────────────────────────────────────────┐
│ GriddyProps<T> │
│ data: T[], columns: GriddyColumn<T>[], config... │
└──────────────────────┬──────────────────────────────┘
│
┌────────────▼────────────┐
│ useReactTable() │
│ @tanstack/react-table │
│ │
│ • ColumnDef<T>[] built │
│ from GriddyColumn<T> │
│ • Sorting state │
│ • Filtering state │
│ • Row selection state │
│ • Pagination state │
│ • Column visibility │
│ • Column ordering │
│ • Grouping state │
│ │
│ Output: table instance │
│ → table.getRowModel() │
│ → table.getHeaderGroups│
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ useVirtualizer() │
│ @tanstack/react-virtual│
│ │
│ count = table │
│ .getRowModel() │
│ .rows.length │
│ │
│ Output: virtualRows[] │
│ (only visible window) │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Render Loop │
│ │
│ for each virtualRow: │
│ row = tableRows │
│ [virtualRow.index] │
│ for each cell in row:│
│ render cell via │
│ flexRender() │
└─────────────────────────┘
Key integration points:
useReactTable()produces the full sorted/filtered/grouped row modeluseVirtualizer()receivestable.getRowModel().rows.lengthas itscount- The render loop maps virtual items back to TanStack Table rows by index
- TanStack Table owns all state for sorting, filtering, selection, pagination
- The Zustand store owns UI state: focused row, edit mode, search overlay, keyboard navigation position
3. Component Structure
Griddy/
├── core/
│ ├── Griddy.tsx # Main component, orchestrates table + virtualizer
│ ├── GriddyStore.ts # Zustand store for UI state (focus, edit mode, search)
│ ├── GriddyProvider.tsx # Context provider: table instance + store + config
│ ├── GriddyTable.tsx # TanStack Table setup (useReactTable call)
│ ├── types.ts # Core types: GriddyColumn<T>, GriddyProps<T>, etc.
│ ├── columnMapper.ts # Maps GriddyColumn<T> → TanStack ColumnDef<T>
│ ├── defaults.ts # Default config values
│ └── constants.ts # Key codes, CSS class names, etc.
├── rendering/
│ ├── VirtualBody.tsx # Virtual row rendering via useVirtualizer
│ ├── TableHeader.tsx # Renders table.getHeaderGroups()
│ ├── TableRow.tsx # Renders a single row's cells via flexRender
│ ├── TableCell.tsx # Individual cell with edit/render mode
│ └── hooks/
│ └── useGridVirtualizer.ts # Wraps useVirtualizer with grid-specific config
├── features/
│ ├── keyboard/
│ │ ├── useKeyboardNavigation.ts # Master keyboard handler
│ │ ├── keyMap.ts # Key binding definitions
│ │ └── types.ts
│ ├── selection/
│ │ ├── SelectionCheckbox.tsx # Row selection checkbox column
│ │ ├── useGridSelection.ts # Wraps TanStack Table row selection
│ │ └── types.ts
│ ├── filtering/
│ │ ├── FilterControl.tsx
│ │ ├── useGridFiltering.ts # Wraps TanStack Table column filters
│ │ ├── types.ts
│ │ └── operators.ts
│ ├── sorting/
│ │ ├── SortIndicator.tsx
│ │ ├── useGridSorting.ts # Wraps TanStack Table sorting
│ │ └── types.ts
│ ├── search/
│ │ ├── SearchOverlay.tsx # Ctrl+F search overlay UI
│ │ ├── useGridSearch.ts # Global filter via TanStack Table
│ │ └── types.ts
│ ├── editing/
│ │ ├── EditableCell.tsx
│ │ ├── useGridEditing.ts
│ │ ├── validation.ts
│ │ └── types.ts
│ ├── pagination/
│ │ ├── PaginationControl.tsx
│ │ ├── useGridPagination.ts # Wraps TanStack Table pagination
│ │ └── types.ts
│ ├── grouping/
│ │ ├── GroupHeader.tsx
│ │ ├── useGridGrouping.ts # Wraps TanStack Table grouping
│ │ └── types.ts
│ ├── pinning/
│ │ ├── useGridPinning.ts # Wraps TanStack Table column pinning
│ │ └── types.ts
│ ├── resizing/
│ │ ├── useGridResizing.ts # Wraps TanStack Table column resizing
│ │ └── types.ts
│ └── export/
│ └── exportCsv.ts
├── editors/
│ ├── TextEditor.tsx
│ ├── NumericEditor.tsx
│ ├── DateEditor.tsx
│ ├── SelectEditor.tsx
│ ├── CheckboxEditor.tsx
│ └── index.ts
├── renderers/
│ ├── defaultRenderers.tsx
│ ├── formatters.ts
│ └── types.ts
├── adapters/
│ ├── LocalDataAdapter.ts
│ ├── RemoteServerAdapter.ts
│ ├── base.ts
│ └── types.ts
├── hooks/
│ ├── useGriddy.ts # Main hook: composes table + virtualizer + store
│ ├── useMergedRefs.ts
│ ├── useAsync.ts
│ └── index.ts
├── styles/
│ ├── griddy.module.css
│ ├── themes.ts
│ ├── variables.css
│ └── reset.css
├── utils/
│ ├── merge.ts
│ ├── classNames.ts
│ ├── accessibility.ts
│ └── index.ts
├── index.ts # Main export
├── Griddy.test.tsx
└── README.md
Feature Specifications
Core Features
1. TanStack Table Integration
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:
// Griddy's user-facing column API
interface GriddyColumn<T> {
id: string
header: string | ReactNode
accessor: keyof T | ((row: T) => any)
width?: number
minWidth?: number
maxWidth?: number
pinned?: 'left' | 'right'
sortable?: boolean // maps to enableSorting
filterable?: boolean // maps to enableColumnFilter
searchable?: boolean // included in global filter
editable?: boolean | ((row: T) => boolean)
editor?: EditorComponent<T>
renderer?: CellRenderer<T>
headerGroup?: string
hidden?: boolean // maps to column visibility
sortFn?: SortingFn<T> // custom TanStack sort function
filterFn?: FilterFn<T> // custom TanStack filter function
}
// Internal: columnMapper.ts converts GriddyColumn<T> → ColumnDef<T>
function mapColumns<T>(columns: GriddyColumn<T>[]): ColumnDef<T>[] {
return columns.map(col => ({
id: col.id,
accessorFn: typeof col.accessor === 'function'
? col.accessor
: (row: T) => row[col.accessor as keyof T],
header: col.header,
cell: col.renderer
? ({ getValue, row, column }) => col.renderer!({ value: getValue(), row: row.original, column: col, ... })
: undefined,
size: col.width,
minSize: col.minWidth ?? 50,
maxSize: col.maxWidth,
enableSorting: col.sortable ?? true,
enableColumnFilter: col.filterable ?? false,
sortingFn: col.sortFn,
filterFn: col.filterFn,
enablePinning: col.pinned !== undefined,
enableHiding: true,
meta: { griddy: col }, // preserve original GriddyColumn for editors, etc.
}))
}
Table Instance Setup (in GriddyTable.tsx / useGriddy.ts):
const table = useReactTable<T>({
data,
columns: mappedColumns,
state: {
sorting,
columnFilters,
globalFilter,
rowSelection,
columnVisibility,
columnOrder,
columnPinning,
pagination,
grouping,
expanded,
},
// Feature toggles
enableRowSelection: selectionConfig.mode !== 'none',
enableMultiRowSelection: selectionConfig.multiSelect,
enableSorting: true,
enableFilters: true,
enableColumnResizing: true,
enableGrouping: groupingConfig?.enabled ?? false,
enablePinning: true,
// State handlers
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onPaginationChange: setPagination,
// Pipeline
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: paginationConfig?.enabled ? getPaginationRowModel() : undefined,
getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined,
getExpandedRowModel: getExpandedRowModel(),
});
2. Virtualization (TanStack Virtual)
TanStack Virtual renders only visible rows from the TanStack Table row model.
// In useGridVirtualizer.ts
const rowModel = table.getRowModel();
const virtualizer = useVirtualizer({
count: rowModel.rows.length,
getScrollElement: () => scrollContainerRef.current,
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];
// render row.getVisibleCells() via flexRender
});
- Row Virtualization: Only visible rows rendered, powered by TanStack Virtual
- Scrolling Performance: Configurable overscan (default 10 rows)
- Height Calculation: Fixed height container with configurable row height
- Sticky Headers: CSS
position: stickyon<thead> - 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
- Offset-Based Paging: Traditional offset/limit paging via TanStack Table pagination
- Lazy Loading: Data loaded as user scrolls (virtual scrolling integration)
- Data Adapters: Pluggable adapters for different data sources
API:
interface GriddyDataSource<T> {
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>;
// Keyboard
keyboardNavigation?: boolean; // default: true
// Selection
selection?: SelectionConfig;
onRowSelectionChange?: (selection: Record<string, boolean>) => void;
// Sorting
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
// Filtering
columnFilters?: ColumnFiltersState;
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
// Search
search?: SearchConfig;
// Editing
onEditCommit?: (rowId: string, columnId: string, value: any) => void | Promise<void>;
// Pagination
pagination?: PaginationConfig;
// Virtualization
rowHeight?: number; // default: 36
overscan?: number; // default: 10
height?: number | string; // container height
// Persistence
persistenceKey?: string; // localStorage key prefix
}
4. Column Management
Powered by TanStack Table's column APIs:
- Column Definition: GriddyColumn mapped to TanStack ColumnDef
- Header Grouping: TanStack Table
getHeaderGroups()for multi-level headers - Column Pinning: TanStack Table
columnPinningstate - Column Resizing: TanStack Table
enableColumnResizing+ drag handlers - Column Hiding: TanStack Table
columnVisibilitystate - Column Reordering: TanStack Table
columnOrderstate + drag-and-drop - Header Customization: Custom header via
headerfield in column definition
5. Filtering
Powered by TanStack Table's filtering pipeline:
- Column Filtering:
enableColumnFilterper column,getFilteredRowModel() - Filter Modes: Built-in TanStack filter functions + custom
filterFnper column - Multi-Filter: Multiple column filters applied simultaneously (AND logic by default)
- Filter Persistence: Save/restore
columnFiltersstate - Custom Filters: User-provided
filterFnon column definition
6. Search
Global search powered by TanStack Table's globalFilter:
- Global Search:
setGlobalFilter()searches across all columns withsearchable: true - Search Overlay: Ctrl+F opens search overlay UI (custom, not browser find)
- Search Highlighting: Custom cell renderer highlights matching text
- Search Navigation: Navigate through matches with Enter/Shift+Enter in overlay
- Fuzzy Search: Optional via custom global filter function
API:
interface SearchConfig {
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:
sortFnon column definition → TanStacksortingFn - Sort Persistence: Save/restore
sortingstate - Server-Side Sort: When
manualSorting: true, callbacks handle sort externally
Keyboard Navigation (Core Feature)
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)
interface GriddyUIState {
// 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)
// 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;
}
Key Bindings
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 |
Implementation: useKeyboardNavigation.ts
function useKeyboardNavigation(
table: Table<any>,
virtualizer: Virtualizer<HTMLDivElement, Element>,
store: GriddyUIState,
config: {
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;
// Search mode: only Escape exits
if (isSearchOpen) {
if (e.key === 'Escape') {
store.getState().setSearchOpen(false);
e.preventDefault();
}
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();
}
return; // let editor handle its own keys
}
// 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 === 'PageDown':
e.preventDefault();
store.getState().moveFocus('down', 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 === '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 === '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 === ' ':
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 === '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;
}
// 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]);
}
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.
.griddy-row--focused {
outline: 2px solid var(--griddy-focus-color, #228be6);
outline-offset: -2px;
z-index: 1;
}
.griddy-row--selected {
background-color: var(--griddy-selection-bg, rgba(34, 139, 230, 0.1));
}
.griddy-row--focused.griddy-row--selected {
outline: 2px solid var(--griddy-focus-color, #228be6);
background-color: var(--griddy-selection-bg, rgba(34, 139, 230, 0.1));
}
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.
Row Selection (Core Feature)
Row selection is powered by TanStack Table's row selection (enableRowSelection, onRowSelectionChange). Griddy adds keyboard-driven selection on top.
Selection Modes
interface SelectionConfig {
/** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */
mode: 'none' | 'single' | 'multi';
/** Show checkbox column (auto-added as first column) */
showCheckbox?: boolean; // default: true when mode !== 'none'
/** Allow clicking row body to toggle selection */
selectOnClick?: boolean; // default: true
/** Maintain selection across pagination/sorting */
preserveSelection?: boolean; // default: true
/** Callback when selection changes */
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
- Ctrl+Click (or Cmd+Click on Mac) toggles individual without affecting others
- Keyboard:
Spacetoggles focused rowShift+ArrowUp/Downextends selectionCtrl+Aselects allEscapeclears selection
- TanStack Table config:
enableRowSelection: true,enableMultiRowSelection: true
Checkbox Column
When showCheckbox is true (default for selection modes), a checkbox column is automatically prepended:
// Auto-injected via columnMapper when selection is enabled
const checkboxColumn: ColumnDef<T> = {
id: '_selection',
header: ({ table }) => (
selectionConfig.mode === 'multi' ? (
<SelectionCheckbox
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
) : null
),
cell: ({ row }) => (
<SelectionCheckbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
size: 40,
enableSorting: false,
enableColumnFilter: false,
enableResizing: false,
enablePinning: false,
}
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.
// Controlled selection
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// In useReactTable:
{
state: { rowSelection },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
enableMultiRowSelection: config.selection.mode === 'multi',
getRowId: config.getRowId ?? ((row, index) => String(index)),
}
In-Place Editing
Edit Mode Activation
Ctrl+EorEnteron a focused row opens the first editable cell for editing- Double-click on a cell opens it for editing
- When editing:
Tabmoves to next editable cell in the rowShift+Tabmoves to previous editable cellEntercommits the edit and moves focus to the next rowEscapecancels the edit
Editor Components
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
}
Built-in editors: TextEditor, NumericEditor, DateEditor, SelectEditor, CheckboxEditor.
Search Overlay
Behavior
Ctrl+Fopens a search overlay bar at the top of the grid- Search input is auto-focused
- Typing updates
table.setGlobalFilter()with debounce - Matching cells are highlighted via custom cell renderer
Enterin search input navigates to next matchShift+Enternavigates to previous matchEscapecloses overlay and clears search
Implementation
// SearchOverlay.tsx
function SearchOverlay({ table, store }: { table: Table<any>, store: GriddyUIState }) {
const [query, setQuery] = useState('')
const debouncedFilter = useDebouncedCallback((value: string) => {
table.setGlobalFilter(value || undefined)
}, 300)
return (
<div className="griddy-search-overlay">
<input
autoFocus
value={query}
onChange={e => {
setQuery(e.target.value)
debouncedFilter(e.target.value)
}}
onKeyDown={e => {
if (e.key === 'Escape') {
table.setGlobalFilter(undefined)
store.setSearchOpen(false)
}
}}
placeholder="Search..."
/>
</div>
)
}
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
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;
}
Keyboard interaction with pagination:
PageUp/PageDownmove 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
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.)
- Expand/Collapse:
row.toggleExpanded(), keyboard:ArrowRightto expand,ArrowLeftto collapse
Column Pinning
Powered by TanStack Table's column pinning:
// TanStack Table state
columnPinning: {
left: ['_selection', 'id'], // pinned left columns
right: ['actions'], // pinned right columns
}
Rendering uses table.getLeftHeaderGroups(), table.getCenterHeaderGroups(), table.getRightHeaderGroups() for separate sticky regions.
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):
const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
GriddyStoreState, // UI state + prop fields + internal refs
GriddyProps<any> // Props synced from parent
>(
(set, get) => ({
// UI state
focusedRowIndex: null,
isEditing: false,
isSearchOpen: false,
isSelecting: false,
// Internal refs
_table: null,
_virtualizer: null,
// Actions
setFocusedRow: (index) => set({ focusedRowIndex: index }),
moveFocus: (direction, amount) => { ... },
setTable: (table) => set({ _table: table }),
...
})
)
// Usage: <GriddyProvider {...props}><GriddyInner /></GriddyProvider>
// All props (data, columns, selection, etc.) are available via useGriddyStore((s) => s.data)
2. Data Adapter Pattern
Adapters feed data into TanStack Table:
// LocalDataAdapter: passes array directly to table
// RemoteServerAdapter: fetches data, manages loading state, handles pagination callbacks
// CustomAdapter: user-defined data fetching
3. Event System
CustomEvent for inter-component communication (same as Gridler):
state._events.dispatchEvent(new CustomEvent('loadPage', { detail }));
state._events.addEventListener('reload', handler);
4. Ref-Based Imperative API
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;
}
5. Persistence Layer
persist={{
name: `Griddy_${props.persistenceKey}`,
partialize: (s) => ({
columnOrder: s.columnOrder,
columnSizing: s.columnSizing,
columnVisibility: s.columnVisibility,
sorting: s.sorting,
}),
version: 1,
}}
Implementation Phases
Phase 1: Core Foundation + TanStack Table
- Set up Griddy package structure
- Install
@tanstack/react-tableas dependency - Create core types:
GriddyColumn<T>,GriddyProps<T>,SelectionConfig, etc. - Implement
columnMapper.ts(GriddyColumn → ColumnDef) - Implement
Griddy.tsxwithuseReactTable()setup - Implement basic HTML table rendering with
flexRender() - Implement
GriddyProvider.tsxwith context for table instance - Implement
GriddyStore.ts(Zustand store for UI state) - Implement
LocalDataAdapter - Add Storybook stories for basic table
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.tsxwith virtual row rendering - Implement
TableHeader.tsxwith sticky headers - Implement
useKeyboardNavigation.tswith full key bindings:- Arrow Up/Down for row focus
- Page Up/Down for page-sized jumps
- Home/End for first/last row
- Auto-scroll focused row into view
- Implement focused row visual indicator (CSS)
- Performance optimization (memo, useMemo)
- Test with large datasets (10k+ rows)
- Add column resizing via TanStack Table
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) - Keyboard selection:
- Space to toggle
- Shift+Arrow to extend (multi)
- Ctrl+A to select all (multi)
- Ctrl+S to toggle selection mode
- Escape to clear selection
- Click-to-select and Shift+Click range selection
- Selection persistence across sort/filter
onSelectionChangecallback with selected rows
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
- Search navigation (Enter/Shift+Enter through matches)
- Debounced input
- Escape to close and clear
Deliverable: Global search with keyboard-activated overlay
Phase 5: Sorting & Filtering
- Sorting via TanStack Table (click header, Shift+Click for multi)
- Sort indicators in headers
- Column filtering UI (right-click context menu for sort/filter options)
- Filter operators (contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty, between, greaterThan, lessThan, includes, excludes, etc.)
- Text, number, enum, and boolean filter types
- Filter UI with operator dropdown and type-specific inputs
- Filter status indicators (blue/gray icons in headers)
- Debounced text input (300ms)
- Apply/Clear buttons for filter controls
- Date filtering (Phase 5.5 - COMPLETE with @mantine/dates)
- Server-side sort/filter support (
manualSorting,manualFiltering) - COMPLETE - Sort/filter state persistence
Deliverable: Complete data manipulation features powered by TanStack Table
Files Created (9 components):
src/Griddy/features/filtering/types.ts— Filter type systemsrc/Griddy/features/filtering/operators.ts— Operator definitions for all 4 typessrc/Griddy/features/filtering/filterFunctions.ts— TanStack FilterFn implementationssrc/Griddy/features/filtering/FilterInput.tsx— Text/number input with debouncingsrc/Griddy/features/filtering/FilterSelect.tsx— Multi-select for enumssrc/Griddy/features/filtering/FilterBoolean.tsx— Radio group for booleanssrc/Griddy/features/filtering/ColumnFilterButton.tsx— Filter status iconsrc/Griddy/features/filtering/ColumnFilterPopover.tsx— Filter UI popoversrc/Griddy/features/filtering/ColumnFilterContextMenu.tsx— Right-click context menu
Files Modified:
src/Griddy/rendering/TableHeader.tsx— Integrated context menu + filter popoversrc/Griddy/core/columnMapper.ts— Set default filterFn for filterable columnssrc/Griddy/core/types.ts— Added FilterConfig to GriddyColumnsrc/Griddy/core/constants.ts— Added CSS class names and defaultssrc/Griddy/styles/griddy.module.css— Filter UI stylingsrc/Griddy/Griddy.stories.tsx— Added 6 filtering examples
Tests:
playwright.config.ts— Playwright configurationtests/e2e/filtering-context-menu.spec.ts— 8 comprehensive E2E test cases
Phase 6: In-Place Editing
- Implement
EditableCell.tsxwith editor mounting - Implement built-in editors: Text, Numeric, Date, Select, Checkbox
- Keyboard editing:
- Ctrl+E or Enter to start editing
- Tab/Shift+Tab between editable cells (partial - editors handle Tab)
- Enter to commit
- Escape to cancel
onEditCommitcallback- Double-click to edit
- Editor types: text, number, date, select, checkbox
- Validation system (deferred)
- Tab to next editable cell navigation (deferred)
- Undo/redo (optional, deferred)
Deliverable: Full in-place editing with keyboard support - COMPLETE ✅
Phase 7: Pagination & Data Adapters
- Client-side pagination via TanStack Table
getPaginationRowModel() - Pagination controls UI (page nav, page size selector)
- Server-side pagination callbacks (
onPageChange,onPageSizeChange) - Page navigation controls (first, previous, next, last)
- Page size selector dropdown
- Storybook stories (client-side + server-side)
- Implement
RemoteServerAdapterwith cursor + offset support (deferred - callbacks sufficient) - Loading states UI (deferred - handled externally)
- Infinite scroll pattern (optional, deferred)
Deliverable: Pagination and remote data support - COMPLETE ✅
Phase 8: Advanced Features
- Column hiding/visibility (TanStack
columnVisibility) - COMPLETE - Export to CSV - COMPLETE
- Toolbar component (column visibility + export) - COMPLETE
- Column pinning via TanStack Table
columnPinning✅ - Header grouping via TanStack Table
getHeaderGroups()✅ - Data grouping via TanStack Table
getGroupedRowModel()✅ - Column reordering (drag-and-drop + TanStack
columnOrder) ✅
Deliverable: Advanced table features - PARTIAL ✅ (core features complete)
Phase 9: Polish & Documentation
- Comprehensive Storybook stories (15+ stories covering all features)
- API documentation (README.md with full API reference)
- TypeScript definitions and examples (EXAMPLES.md)
- Integration examples (server-side, custom renderers, etc.)
- Theme system documentation (THEME.md with CSS variables)
- ARIA attributes (grid, row, gridcell, aria-selected, aria-activedescendant)
- Performance benchmarks (deferred - already tested with 10k rows)
Deliverable: Production-ready component - COMPLETE ✅
Technology Stack
- React: 19.x (peer dependency)
- TypeScript: 5.9+
- Table Model: @tanstack/react-table 8.x
- Virtualization: @tanstack/react-virtual 3.13+
- State Management: Zustand (for UI state beyond TanStack Table)
- Styling: CSS Modules + CSS custom properties for theming
- Testing: Vitest, React Testing Library
- Build: Vite with TypeScript declaration generation
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.)
Testing Strategy
-
Unit Tests:
- Column mapper (GriddyColumn → ColumnDef)
- Keyboard navigation logic
- Selection state management
- Editor components
- Filter/Sort operators
- Data adapter functions
-
Integration Tests:
- TanStack Table + Virtual rendering together
- Keyboard navigation with virtualized rows
- Selection with sorting/filtering active
- Editing with validation
- Search with highlighting
-
Performance Tests:
- Rendering 10k+ rows (virtual)
- Filtering/sorting on large datasets (TanStack Table performance)
- Keyboard navigation speed with 100k rows
- Memory usage monitoring
-
Accessibility Tests:
- Full keyboard navigation (all documented bindings)
- ARIA roles:
grid,row,gridcell,columnheader aria-selectedon selected rowsaria-activedescendantfor focused row- Screen reader announcement of sort/filter state
ARIA / Accessibility
The grid follows WAI-ARIA grid pattern:
<div role="grid" aria-label="Data grid" tabindex="0" aria-activedescendant="row-3">
<div role="rowgroup">
<div role="row">
<div role="columnheader" aria-sort="ascending">Name</div>
<div role="columnheader" aria-sort="none">Email</div>
</div>
</div>
<div role="rowgroup">
<div role="row" id="row-3" aria-rowindex="4" aria-selected="true">
<div role="gridcell">John</div>
<div role="gridcell">john@example.com</div>
</div>
</div>
</div>
Browser Support
- Chrome/Edge: Latest 2 versions
- Firefox: Latest 2 versions
- Safari: Latest 2 versions
- No IE11 support (modern React requirement)
Success Criteria
- Table renders 10k+ rows smoothly (60fps) via TanStack Virtual
- All table logic (sort, filter, select, paginate) handled by TanStack Table
- Full keyboard navigation: Arrow keys, PageUp/Down, Home/End, Ctrl+F, Ctrl+E/Enter, Ctrl+S
- Single and multi row selection with keyboard and mouse
- TypeScript types cover 95%+ of code
- Bundle size < 50KB (gzipped, excluding peer deps)
- Full keyboard accessibility (WCAG AA)
- All features tested with >80% coverage
Known Limitations (Phase 1)
- Column virtualization: Deferred (TanStack Virtual supports it, but not needed initially)
- Tree/hierarchical data: Planned for later
- Copy/paste: Planned for later
- Rich text/HTML content: Not planned
- Master-detail/expandable rows: Planned for later
- Cell-level focus (left/right arrow between cells): Planned for later; Phase 1 focuses on row-level navigation
Phase 10: Future Enhancements
Bug Fixes
- Unique Row ID - Add a unique row ID to the data if no uniqueId is provided.
- Tree row selection - The tree row selection breaks, it selects the same item. Suspect the uniqueId
- Infinite scroll - Header Spec infinite scroll server side filtering and sorting not working
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
- RemoteServerAdapter class - Formal adapter pattern for server data (currently using callbacks)
- Error boundary - Graceful error handling with retry (GriddyErrorBoundary, onError/onRetry props) ✅
- Loading states UI - Skeleton rows with shimmer + translucent overlay spinner (isLoading prop) ✅
Advanced Data Features
- 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
- Tree Structure Column - Parent-child rows with expand/collapse (nested data structures) ✅
- On Demand Expand - Lazy loading with getChildren callback ✅
- On Search Callback - Auto-expand parent nodes when search matches children ✅
- 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
- Custom cell renderers - ProgressBar, Badge, Image, Sparkline renderers via
renderer+rendererMeta✅
Filtering & Search
- Quick filters - Checkbox list of unique values in filter popover (
filterConfig.quickFilter: true) ✅ - Advanced search - Multi-condition search with AND/OR/NOT operators (AdvancedSearchPanel) ✅
- Filter presets - Save/load/delete named filter presets to localStorage (FilterPresetsMenu) ✅
- 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
- Mobile/touch support - Touch gestures for scroll, select, swipe actions
- Responsive columns - Hide/show columns based on viewport width
- 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
- Lazy loading images - Load images as rows scroll into view
- 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
- E2E test suite - 34 Playwright tests: 8 filtering + 26 Phase 10 feature tests, all passing ✅
- Visual regression tests - Screenshot comparison tests
- 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
- Migration guide - Gridler → Griddy migration documentation
- CodeSandbox examples - Live playground with all features
- 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
- Column spanning - Cells that span multiple columns
- Row spanning - Cells that span multiple rows
- Conditional formatting - Highlight cells based on rules
- Formulas - Excel-like formulas for calculated columns
- Real-time collaboration - Multiple users editing simultaneously
Implementation Priority
High Priority (Next):
- Column layout persistence
- Validation system for editors
- Tab-to-next-editable-cell navigation
- Context menu enhancements
Medium Priority:
- Tree/hierarchical data
- Master-detail rows
- Export enhancements (selected rows, Excel format)
- Keyboard shortcuts help overlay
- Copy/paste support
Low Priority (Nice to have):
- Mobile/touch support
- Plugin architecture
- Undo/redo
- Real-time collaboration
- Column/row spanning
Completed Milestones
- ✅ Phase 1-9: Core, virtualization, selection, search, filtering, editing, pagination, advanced features, polish
- ✅ Phase 7.5: Infinite scroll
- ✅ Phase 8 completion: Column pinning, header grouping, data grouping, column reordering
- ✅ Phase 10 batch 1 (7 features): Error boundary, loading states, custom renderers, quick filters, advanced search, filter presets, search history
- ✅ E2E test suite: 34 Playwright tests (all passing)
- ✅ Tree/Hierarchical Data: Full tree support with nested/flat/lazy modes, keyboard navigation, search auto-expand
Next Steps
- Choose remaining Phase 10 features based on user needs
- Column layout persistence (highest priority remaining)
- Validation system for editors
- Update main package README with Griddy documentation