Files
oranguru/src/Griddy/plan.md
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

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 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.
  • 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 createSyncStore for 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:

  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
  4. TanStack Table owns all state for sorting, filtering, selection, pagination
  5. 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: sticky on <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 columnPinning state
  • Column Resizing: TanStack Table enableColumnResizing + drag handlers
  • Column Hiding: TanStack Table columnVisibility state
  • Column Reordering: TanStack Table columnOrder state + drag-and-drop
  • 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)
  • Filter Persistence: Save/restore columnFilters state
  • Custom Filters: User-provided filterFn on column definition

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
  • 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: sortFn on column definition → TanStack sortingFn
  • Sort Persistence: Save/restore sorting state
  • 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:
    • Space toggles focused row
    • Shift+ArrowUp/Down extends selection
    • Ctrl+A selects all
    • Escape clears 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+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:
    • Tab moves to next editable cell in the row
    • Shift+Tab moves to previous editable cell
    • Enter commits the edit and moves focus to the next row
    • Escape cancels 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+F opens 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
  • Enter in search input navigates to next match
  • Shift+Enter navigates to previous match
  • Escape closes 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 / 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

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: ArrowRight to expand, ArrowLeft to 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-table as dependency
  • Create core types: GriddyColumn<T>, GriddyProps<T>, SelectionConfig, etc.
  • Implement columnMapper.ts (GriddyColumn → ColumnDef)
  • Implement Griddy.tsx with useReactTable() setup
  • Implement basic HTML table rendering with flexRender()
  • Implement GriddyProvider.tsx with 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.tsx with virtual row rendering
  • Implement TableHeader.tsx with sticky headers
  • Implement useKeyboardNavigation.ts with 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
  • onSelectionChange callback with selected rows

Deliverable: Full row selection with single and multi modes, keyboard support

  • 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 system
  • src/Griddy/features/filtering/operators.ts — Operator definitions for all 4 types
  • src/Griddy/features/filtering/filterFunctions.ts — TanStack FilterFn implementations
  • src/Griddy/features/filtering/FilterInput.tsx — Text/number input with debouncing
  • src/Griddy/features/filtering/FilterSelect.tsx — Multi-select for enums
  • src/Griddy/features/filtering/FilterBoolean.tsx — Radio group for booleans
  • src/Griddy/features/filtering/ColumnFilterButton.tsx — Filter status icon
  • src/Griddy/features/filtering/ColumnFilterPopover.tsx — Filter UI popover
  • 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
  • src/Griddy/core/constants.ts — Added CSS class names and defaults
  • src/Griddy/styles/griddy.module.css — Filter UI styling
  • 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

  • Implement EditableCell.tsx with 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
  • onEditCommit callback
  • 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 RemoteServerAdapter with 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

  1. Unit Tests:

    • Column mapper (GriddyColumn → ColumnDef)
    • Keyboard navigation logic
    • Selection state management
    • Editor components
    • Filter/Sort operators
    • Data adapter functions
  2. Integration Tests:

    • TanStack Table + Virtual rendering together
    • Keyboard navigation with virtualized rows
    • Selection with sorting/filtering active
    • Editing with validation
    • Search with highlighting
  3. Performance Tests:

    • Rendering 10k+ rows (virtual)
    • Filtering/sorting on large datasets (TanStack Table performance)
    • Keyboard navigation speed with 100k rows
    • Memory usage monitoring
  4. Accessibility Tests:

    • Full keyboard navigation (all documented bindings)
    • ARIA roles: grid, row, gridcell, columnheader
    • aria-selected on selected rows
    • aria-activedescendant for 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

  1. Table renders 10k+ rows smoothly (60fps) via TanStack Virtual
  2. All table logic (sort, filter, select, paginate) handled by TanStack Table
  3. Full keyboard navigation: Arrow keys, PageUp/Down, Home/End, Ctrl+F, Ctrl+E/Enter, Ctrl+S
  4. Single and multi row selection with keyboard and mouse
  5. TypeScript types cover 95%+ of code
  6. Bundle size < 50KB (gzipped, excluding peer deps)
  7. Full keyboard accessibility (WCAG AA)
  8. 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
  • 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):

  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)
  4. Keyboard shortcuts help overlay
  5. Copy/paste support

Low Priority (Nice to have):

  1. Mobile/touch support
  2. Plugin architecture
  3. Undo/redo
  4. Real-time collaboration
  5. Column/row spanning

Completed Milestones

  1. Phase 1-9: Core, virtualization, selection, search, filtering, editing, pagination, advanced features, polish
  2. Phase 7.5: Infinite scroll
  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

  1. Choose remaining Phase 10 features based on user needs
  2. Column layout persistence (highest priority remaining)
  3. Validation system for editors
  4. Update main package README with Griddy documentation