feat(search): add search history functionality with dropdown and persistence

- Implement SearchHistoryDropdown component for displaying recent searches
- Add useSearchHistory hook for managing search history in localStorage
- Integrate search history into SearchOverlay for user convenience
- Update GridToolbar to support filter presets
- Enhance SearchOverlay with close button and history display
This commit is contained in:
2026-02-15 13:52:36 +02:00
parent 6226193ab5
commit 9ec2e73640
42 changed files with 2026 additions and 780 deletions

1
.gitignore vendored
View File

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

View File

@@ -13,7 +13,7 @@ const preview: Preview = {
},
},
layout: 'fullscreen',
viewMode: 'responsive',
viewMode: 'desktop',
},
};

View File

@@ -1,56 +1,148 @@
# Griddy - Implementation Context
## What Is This
Griddy is a new data grid component in the Oranguru package (`@warkypublic/oranguru`), replacing Glide Data Grid (used by Gridler) with TanStack Table + TanStack Virtual.
Griddy is a data grid component in the Oranguru package (`@warkypublic/oranguru`), built on TanStack Table + TanStack Virtual with Zustand state management.
## Architecture
### Two TanStack Libraries
- **@tanstack/react-table** (headless table model): owns sorting, filtering, pagination, row selection, column visibility, grouping state
- **@tanstack/react-table** (headless table model): sorting, filtering, pagination, row selection, column visibility, grouping
- **@tanstack/react-virtual** (virtualization): renders only visible rows from the table's row model
### State Management
- **createSyncStore** from `@warkypublic/zustandsyncstore` — same pattern as Gridler's `GridlerStore.tsx`
- **createSyncStore** from `@warkypublic/zustandsyncstore`
- `GriddyProvider` wraps children; props auto-sync into the store via `$sync`
- `useGriddyStore((s) => s.fieldName)` to read any prop or UI state
- `GriddyStoreState` must explicitly declare all prop fields from `GriddyProps` for TypeScript visibility (the sync happens at runtime but TS needs the types)
- `GriddyStoreState` must explicitly declare all prop fields from `GriddyProps` for TypeScript visibility
- UI state (focus, edit mode, search overlay, selection mode) lives in the store
- TanStack Table/Virtual instances stored as `_table`, `_virtualizer` in the store
### Component Tree
```
<Griddy props> // forwardRef wrapper
<GriddyProvider {...props}> // createSyncStore Provider, syncs all props
<GriddyInner> // sets up useReactTable + useVirtualizer
<SearchOverlay /> // Ctrl+F search (Mantine TextInput)
<div tabIndex={0}> // scroll container, keyboard target
<TableHeader /> // renders table.getHeaderGroups()
<VirtualBody /> // maps virtualizer items → TableRow
<TableRow /> // focus/selection CSS, click handler
<TableCell /> // flexRender or Mantine Checkbox
</div>
</GriddyInner>
<Griddy props> // forwardRef wrapper
<GriddyProvider {...props}> // createSyncStore Provider, syncs all props
<GriddyErrorBoundary> // class-based error boundary with retry
<GriddyInner> // sets up useReactTable + useVirtualizer
<SearchOverlay /> // Ctrl+F search (with search history)
<AdvancedSearchPanel /> // multi-condition boolean search
<GridToolbar /> // export, column visibility, filter presets
<div tabIndex={0}> // scroll container, keyboard target
<TableHeader /> // headers, sort indicators, filter popovers
<GriddyLoadingSkeleton /> // shown when isLoading && no data
<VirtualBody /> // maps virtualizer items -> TableRow
<TableRow /> // focus/selection CSS, click handler
<TableCell /> // flexRender, editors, custom renderers
<GriddyLoadingOverlay /> // translucent overlay when loading with data
</div>
<PaginationControl /> // page nav, page size selector
</GriddyInner>
</GriddyErrorBoundary>
</GriddyProvider>
</Griddy>
```
## Key Files
## File Structure
```
src/Griddy/
├── core/
│ ├── Griddy.tsx # Main component, useReactTable + useVirtualizer
│ ├── GriddyStore.ts # Zustand store (createSyncStore)
│ ├── types.ts # All interfaces: GriddyColumn, GriddyProps, GriddyRef, etc.
│ ├── columnMapper.ts # GriddyColumn -> TanStack ColumnDef, checkbox column
│ └── constants.ts # CSS class names, defaults (row height 36, overscan 10)
├── rendering/
│ ├── VirtualBody.tsx # Virtual row rendering
│ ├── TableHeader.tsx # Headers, sort, resize, filter popovers, drag reorder
│ ├── TableRow.tsx # Row with focus/selection styling
│ ├── TableCell.tsx # Cell via flexRender, checkbox, editing
│ ├── EditableCell.tsx # Editor mounting wrapper
│ └── hooks/
│ └── useGridVirtualizer.ts
├── editors/
│ ├── TextEditor.tsx, NumericEditor.tsx, DateEditor.tsx
│ ├── SelectEditor.tsx, CheckboxEditor.tsx
│ ├── types.ts, index.ts
├── features/
│ ├── errorBoundary/
│ │ └── GriddyErrorBoundary.tsx # Class-based, onError/onRetry callbacks
│ ├── loading/
│ │ └── GriddyLoadingSkeleton.tsx # Skeleton rows + overlay spinner
│ ├── renderers/
│ │ ├── ProgressBarRenderer.tsx # Percentage bar via rendererMeta
│ │ ├── BadgeRenderer.tsx # Colored pill badges
│ │ ├── ImageRenderer.tsx # Thumbnail images
│ │ └── SparklineRenderer.tsx # SVG polyline sparklines
│ ├── filtering/
│ │ ├── ColumnFilterPopover.tsx # Filter UI with quick filter integration
│ │ ├── ColumnFilterButton.tsx # Filter icon (forwardRef, onClick toggle)
│ │ ├── ColumnFilterContextMenu.tsx # Right-click: Sort, Open Filters
│ │ ├── FilterInput.tsx, FilterSelect.tsx, FilterBoolean.tsx, FilterDate.tsx
│ │ ├── filterFunctions.ts, operators.ts, types.ts
│ ├── quickFilter/
│ │ └── QuickFilterDropdown.tsx # Checkbox list of unique column values
│ ├── advancedSearch/
│ │ ├── AdvancedSearchPanel.tsx # Multi-condition search panel
│ │ ├── SearchConditionRow.tsx # Single condition: column + operator + value
│ │ ├── advancedFilterFn.ts # AND/OR/NOT filter logic
│ │ └── types.ts
│ ├── filterPresets/
│ │ ├── FilterPresetsMenu.tsx # Save/load/delete presets dropdown
│ │ ├── useFilterPresets.ts # localStorage CRUD hook
│ │ └── types.ts
│ ├── search/
│ │ └── SearchOverlay.tsx # Ctrl+F search with history integration
│ ├── searchHistory/
│ │ ├── SearchHistoryDropdown.tsx # Recent searches dropdown
│ │ └── useSearchHistory.ts # localStorage hook (last 10 searches)
│ ├── keyboard/
│ │ └── useKeyboardNavigation.ts
│ ├── pagination/
│ │ └── PaginationControl.tsx
│ ├── toolbar/
│ │ └── GridToolbar.tsx # Export, column visibility, filter presets
│ ├── export/
│ │ └── exportCsv.ts
│ └── columnVisibility/
│ └── ColumnVisibilityMenu.tsx
├── styles/
│ └── griddy.module.css
├── index.ts
└── Griddy.stories.tsx # 31 stories covering all features
| File | Purpose |
|------|---------|
| `core/types.ts` | All interfaces: GriddyColumn, GriddyProps, GriddyRef, GriddyUIState, SelectionConfig, SearchConfig, etc. |
| `core/constants.ts` | CSS class names, defaults (row height 36, overscan 10, page size 50) |
| `core/columnMapper.ts` | Maps GriddyColumn → TanStack ColumnDef. Uses `accessorKey` for strings, `accessorFn` for functions. Auto-prepends checkbox column for selection. |
| `core/GriddyStore.ts` | createSyncStore with GriddyStoreState. Exports `GriddyProvider` and `useGriddyStore`. |
| `core/Griddy.tsx` | Main component. GriddyInner reads props from store, creates useReactTable + useVirtualizer, wires keyboard nav. |
| `rendering/VirtualBody.tsx` | Virtual row rendering. **Important**: all hooks must be before early return (hooks violation fix). |
| `rendering/TableHeader.tsx` | Header with sort indicators, resize handles, select-all checkbox. |
| `rendering/TableRow.tsx` | Row with focus/selection styling, click-to-select. |
| `rendering/TableCell.tsx` | Cell rendering via flexRender, checkbox for selection column. |
| `features/keyboard/useKeyboardNavigation.ts` | Full keyboard handler with ref to latest state. |
| `features/search/SearchOverlay.tsx` | Ctrl+F search overlay with debounced global filter. |
| `styles/griddy.module.css` | CSS Modules with custom properties for theming. |
| `Griddy.stories.tsx` | Storybook stories: Basic, LargeDataset, SingleSelection, MultiSelection, WithSearch, KeyboardNavigation. |
tests/e2e/
├── filtering-context-menu.spec.ts # 8 tests for Phase 5 filtering
└── griddy-features.spec.ts # 26 tests for Phase 10 features
```
## Key Props (GriddyProps<T>)
| Prop | Type | Purpose |
|------|------|---------|
| `data` | `T[]` | Data array |
| `columns` | `GriddyColumn<T>[]` | Column definitions |
| `selection` | `SelectionConfig` | none/single/multi row selection |
| `search` | `SearchConfig` | Ctrl+F search overlay |
| `advancedSearch` | `{ enabled }` | Multi-condition search panel |
| `pagination` | `PaginationConfig` | Client/server-side pagination |
| `grouping` | `GroupingConfig` | Data grouping |
| `isLoading` | `boolean` | Show skeleton/overlay |
| `showToolbar` | `boolean` | Export + column visibility toolbar |
| `filterPresets` | `boolean` | Save/load filter presets |
| `onError` | `(error) => void` | Error boundary callback |
| `onRetry` | `() => void` | Error boundary retry callback |
| `onEditCommit` | `(rowId, colId, value) => void` | Edit callback |
| `manualSorting/manualFiltering` | `boolean` | Server-side mode |
| `persistenceKey` | `string` | localStorage key for presets/history |
## GriddyColumn<T> Key Fields
| Field | Purpose |
|-------|---------|
| `renderer` | Custom cell renderer (wired via columnMapper `def.cell`) |
| `rendererMeta` | Metadata for built-in renderers (colorMap, max, etc.) |
| `filterConfig` | `{ type, quickFilter?, enumOptions? }` |
| `editable` | `boolean \| (row) => boolean` |
| `editorConfig` | Editor-specific config (options, min, max, etc.) |
| `pinned` | `'left' \| 'right'` |
| `headerGroup` | Groups columns under parent header |
## Keyboard Bindings
- Arrow Up/Down: move focus
@@ -61,400 +153,43 @@ Griddy is a new data grid component in the Oranguru package (`@warkypublic/orang
- Ctrl+A: select all (multi mode)
- Ctrl+F: open search overlay
- Ctrl+E / Enter: enter edit mode
- Ctrl+S: toggle selection mode
- Escape: close search / cancel edit / clear selection
## Selection Modes
- `'none'`: no selection
- `'single'`: one row at a time (TanStack `enableMultiRowSelection: false`)
- `'multi'`: multiple rows, checkbox column, shift+click range, ctrl+a
## Gotchas / Bugs Fixed
1. **Hooks violation in VirtualBody**: `useEffect` was after early `return null`. All hooks must run before any conditional return.
2. **sortingFn crash**: Setting `sortingFn: undefined` explicitly overrides TanStack's auto-detection. Fix: use `accessorKey` for string accessors (enables auto-detect), `sortingFn: 'auto'` for function accessors.
3. **createSyncStore typing**: Props synced at runtime via `$sync` but TypeScript only sees `GriddyStoreState`. All prop fields must be declared in the store state interface.
4. **useGriddyStore has no .getState()**: It's a context-based hook, not a vanilla zustand store. Use `useRef` to track latest state for imperative access in event handlers.
5. **Keyboard focus must scroll**; When keyboard focus changes off screen the screen must scroll with
2. **sortingFn crash**: Setting `sortingFn: undefined` explicitly overrides TanStack's auto-detection. Fix: use `accessorKey` for string accessors.
3. **createSyncStore typing**: Props synced at runtime via `$sync` but TypeScript only sees `GriddyStoreState`. All prop fields must be declared in store state interface.
4. **useGriddyStore has no .getState()**: Context-based hook, not vanilla zustand. Use `useRef` for imperative access.
5. **globalFilterFn: undefined breaks search**: Explicitly setting `globalFilterFn: undefined` disables global filtering. Use conditional spread: `...(advancedSearch?.enabled ? { globalFilterFn } : {})`.
6. **Custom renderers not rendering**: `columnMapper.ts` must wire `GriddyColumn.renderer` into TanStack's `ColumnDef.cell`.
7. **Error boundary retry timing**: `onRetry` parent setState must flush before error boundary clears. Use `setTimeout(0)` to defer `setState({ error: null })`.
8. **ColumnFilterButton must forwardRef**: Mantine's `Popover.Target` requires child to forward refs.
9. **Filter popover click propagation**: Clicking filter icon bubbles to header cell (triggers sort). Fix: explicit `onClick` with `stopPropagation` on ColumnFilterButton, not relying on Mantine Popover.Target auto-toggle.
10. **header.getAfter('right')**: Method exists on `Column`, not `Header`. Use `header.column.getAfter('right')`.
## UI Components
Uses **Mantine** components (not raw HTML):
- `Checkbox` from `@mantine/core` for row/header checkboxes
- `TextInput` from `@mantine/core` for search input
- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `Popover`, `Menu`, `ActionIcon` for filtering (Phase 5)
## Phase 5: Column Filtering UI (COMPLETE)
### User Interaction Pattern
1. **Filter Status Indicator**: Gray filter icon in each column header (disabled, non-clickable)
2. **Right-Click Context Menu**: Shows on header right-click with options:
- `Sort` — Toggle column sorting
- `Reset Sorting` — Clear sort (shown only if column is sorted)
- `Reset Filter` — Clear filters (shown only if column has active filter)
- `Open Filters` — Opens filter popover
3. **Filter Popover**: Opened from "Open Filters" menu item
- Positioned below header
- Contains filter operator dropdown and value input(s)
- Apply and Clear buttons
- Filter type determined by `column.filterConfig.type`
### Filter Types & Operators
| Type | Operators | Input Component |
|------|-----------|-----------------|
| `text` | contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty | TextInput with debounce |
| `number` | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between | NumberInput (single or dual for between) |
| `enum` | includes, excludes, isEmpty | MultiSelect with custom options |
| `boolean` | isTrue, isFalse, isEmpty | Radio.Group (True/False/All) |
### API
```typescript
interface FilterConfig {
type: 'text' | 'number' | 'boolean' | 'enum'
operators?: FilterOperator[] // custom operators (optional)
enumOptions?: Array<{ label: string; value: any }> // for enum type
}
// Usage in column definition:
{
id: 'firstName',
accessor: 'firstName',
header: 'First Name',
filterable: true,
filterConfig: { type: 'text' }
}
```
### Key Implementation Details
- **Default filterFn**: Automatically assigned when `filterable: true` and no custom `filterFn` provided
- **Operator-based filtering**: Uses `createOperatorFilter()` that delegates to type-specific implementations
- **Debouncing**: Text inputs debounced 300ms to reduce re-renders
- **TanStack Integration**: Uses `column.setFilterValue()` and `column.getFilterValue()`
- **AND Logic**: Multiple column filters applied together (AND by default)
- **Controlled State**: Filter state managed by parent via `columnFilters` prop and `onColumnFiltersChange` callback
### Files Structure (Phase 5)
```
src/Griddy/features/filtering/
├── types.ts # FilterOperator, FilterConfig, FilterValue
├── operators.ts # TEXT_OPERATORS, NUMBER_OPERATORS, etc.
├── filterFunctions.ts # TanStack FilterFn implementations
├── FilterInput.tsx # Text/number input with debounce
├── FilterSelect.tsx # Multi-select for enums
├── FilterBoolean.tsx # Radio group for booleans
├── ColumnFilterButton.tsx # Status indicator icon
├── ColumnFilterPopover.tsx # Popover UI container
├── ColumnFilterContextMenu.tsx # Right-click context menu
└── index.ts # Public exports
```
Uses **Mantine** components:
- `Checkbox`, `TextInput`, `ActionIcon`, `Popover`, `Menu`, `Button`, `Group`, `Stack`, `Text`
- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `SegmentedControl`, `ScrollArea`
- `@mantine/dates` for DatePickerInput
- `@tabler/icons-react` for icons
## Implementation Status
- [x] Phase 1: Core foundation + TanStack Table
- [x] Phase 2: Virtualization + keyboard navigation
- [x] Phase 3: Row selection (single + multi)
- [x] Phase 4: Search (Ctrl+F overlay)
- [x] Sorting (click header)
- [x] Phase 5: Column filtering UI (COMPLETE ✅)
- Right-click context menu on headers
- Sort, Reset Sort, Reset Filter, Open Filters menu items
- Text, number, enum, boolean filtering
- Filter popover UI with operators
- 6 Storybook stories with examples
- 8 Playwright E2E test cases
- [x] Phase 5.5: Date filtering (COMPLETE ✅)
- Date filter operators: is, isBefore, isAfter, isBetween
- DatePickerInput component integration
- Updated Storybook stories (WithDateFiltering, WithAllFilterTypes, LargeDatasetWithFiltering)
- Filter functions for date comparison
- [x] Server-side filtering/sorting (COMPLETE ✅)
- `manualSorting` and `manualFiltering` props
- `dataCount` prop for total row count
- TanStack Table integration with manual modes
- ServerSideFilteringSorting story demonstrating external data fetching
- [x] Phase 6: In-place editing (COMPLETE ✅)
- 5 built-in editors: TextEditor, NumericEditor, DateEditor, SelectEditor, CheckboxEditor
- EditableCell component with editor mounting
- Keyboard shortcuts: Ctrl+E, Enter (edit), Escape (cancel), Tab (commit and move)
- Double-click to edit
- onEditCommit callback for data mutations
- WithInlineEditing Storybook story
- [x] Phase 7: Pagination (COMPLETE ✅)
- PaginationControl component with Mantine UI
- Client-side pagination (TanStack Table getPaginationRowModel)
- Server-side pagination (onPageChange, onPageSizeChange callbacks)
- Page navigation controls and page size selector
- WithClientSidePagination and WithServerSidePagination stories
- [x] Phase 7: Pagination + remote data adapters (COMPLETE ✅)
- [x] Phase 8: Advanced Features (COMPLETE ✅)
- Column visibility menu with checkboxes
- CSV export function (exportToCsv)
- GridToolbar component
- Column pinning (left/right sticky) ✅
- Header grouping (multi-level headers) ✅
- Data grouping (hierarchical data) ✅
- Column reordering (drag-and-drop) ✅
- [x] Phase 9: Polish & Documentation (COMPLETE ✅)
- README.md with API reference
- EXAMPLES.md with TypeScript examples
- THEME.md with theming guide
- 24 Storybook stories (added 5 new)
- Full accessibility (ARIA)
- [x] Phase 7.5: Infinite Scroll (COMPLETE ✅)
- Threshold-based loading
- onLoadMore callback
- Loading indicator with spinner
- hasMore flag
- [ ] Phase 10: Future Enhancements (see plan.md)
- [x] Phase 1-9: Core, virtualization, selection, search, filtering, editing, pagination, advanced features, polish
- [x] Phase 7.5: Infinite scroll
- [x] Phase 8 completion: Column pinning, header grouping, data grouping, column reordering
- [x] Phase 10 (partial): Error boundary, loading states, custom renderers, quick filters, advanced search, filter presets, search history
- [ ] Phase 10 remaining: See plan.md
## Dependencies Added
- `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies)
- `@mantine/dates` ^8.3.14 (Phase 5.5)
- `dayjs` ^1.11.19 (peer dependency for @mantine/dates)
## E2E Tests
- **34 total Playwright tests** (8 filtering + 26 feature tests)
- All passing against Storybook at `http://localhost:6006`
- Run: `npx playwright test` (requires Storybook running)
## Build & Testing Status
- [x] `pnpm run typecheck` — ✅ PASS (0 errors)
- [x] `pnpm run lint` — ✅ PASS (0 errors in Phase 5 code)
- [x] `pnpm run build` — ✅ PASS
- [x] `pnpm run storybook` — ✅ 6 Phase 5 stories working
- [x] Playwright test suite created (8 E2E test cases)
### Commands
## Commands
```bash
# Run all checks
pnpm run typecheck && pnpm run lint && pnpm run build
# Start Storybook (see filtering stories)
pnpm run storybook
# Install and run Playwright tests
pnpm exec playwright install
pnpm exec playwright test
# Run specific test file
pnpm exec playwright test tests/e2e/filtering-context-menu.spec.ts
# Debug mode
pnpm exec playwright test --debug
# View HTML report
pnpm exec playwright show-report
pnpm run typecheck && pnpm run build # Build check
pnpm run storybook # Start Storybook
npx playwright test # Run E2E tests
npx playwright test tests/e2e/griddy-features.spec.ts # Phase 10 tests only
```
## Recent Completions
### Phase 5.5 - Date Filtering
**Files Created**:
- `src/Griddy/features/filtering/FilterDate.tsx` — Date picker with single/range modes
**Files Modified**:
- `types.ts`, `operators.ts`, `filterFunctions.ts`, `ColumnFilterPopover.tsx`, `index.ts`
- `Griddy.stories.tsx` — WithDateFiltering story
### Server-Side Filtering/Sorting
**Files Modified**:
- `src/Griddy/core/types.ts` — Added `manualSorting`, `manualFiltering`, `dataCount` props
- `src/Griddy/core/GriddyStore.ts` — Added props to store state
- `src/Griddy/core/Griddy.tsx` — Integrated manual modes with TanStack Table
- `src/Griddy/Griddy.stories.tsx` — Added ServerSideFilteringSorting story
### Phase 6 - In-Place Editing (COMPLETE ✅)
**Files Created** (7 editors + 1 component):
- `src/Griddy/editors/types.ts` — Editor type definitions
- `src/Griddy/editors/TextEditor.tsx` — Text input editor
- `src/Griddy/editors/NumericEditor.tsx` — Number input editor with min/max/step
- `src/Griddy/editors/DateEditor.tsx` — Date picker editor
- `src/Griddy/editors/SelectEditor.tsx` — Dropdown select editor
- `src/Griddy/editors/CheckboxEditor.tsx` — Checkbox editor
- `src/Griddy/editors/index.ts` — Editor exports
- `src/Griddy/rendering/EditableCell.tsx` — Cell editing wrapper
**Files Modified** (4):
- `core/types.ts` — Added EditorConfig import, editorConfig to GriddyColumn
- `rendering/TableCell.tsx` — Integrated EditableCell, double-click handler, edit mode detection
- `features/keyboard/useKeyboardNavigation.ts` — Enter/Ctrl+E find first editable column
- `Griddy.stories.tsx` — Added WithInlineEditing story
### Phase 7 - Pagination (COMPLETE ✅)
**Files Created** (2):
- `src/Griddy/features/pagination/PaginationControl.tsx` — Pagination UI with navigation + page size selector
- `src/Griddy/features/pagination/index.ts` — Pagination exports
**Files Modified** (3):
- `core/Griddy.tsx` — Integrated PaginationControl, wired pagination callbacks
- `styles/griddy.module.css` — Added pagination styles
- `Griddy.stories.tsx` — Added WithClientSidePagination and WithServerSidePagination stories
**Features**:
- Client-side pagination (10,000 rows in memory)
- Server-side pagination (callbacks trigger data fetch)
- Page navigation (first, prev, next, last)
- Page size selector (10, 25, 50, 100)
- Pagination state integration with TanStack Table
### Phase 8 - Advanced Features (PARTIAL ✅)
**Files Created** (6):
- `src/Griddy/features/export/exportCsv.ts` — CSV export utility functions
- `src/Griddy/features/export/index.ts` — Export module exports
- `src/Griddy/features/columnVisibility/ColumnVisibilityMenu.tsx` — Column toggle menu
- `src/Griddy/features/columnVisibility/index.ts` — Column visibility exports
- `src/Griddy/features/toolbar/GridToolbar.tsx` — Toolbar with export + column visibility
- `src/Griddy/features/toolbar/index.ts` — Toolbar exports
**Files Modified** (4):
- `core/types.ts` — Added showToolbar, exportFilename props
- `core/GriddyStore.ts` — Added toolbar props to store state
- `core/Griddy.tsx` — Integrated GridToolbar component
- `Griddy.stories.tsx` — Added WithToolbar story
**Features Implemented**:
- Column visibility toggle (show/hide columns via menu)
- CSV export (filtered + visible columns)
- Toolbar component (optional, toggleable)
- TanStack Table columnVisibility state integration
**Deferred**: Column pinning, header grouping, data grouping, column reordering
### Phase 9 - Polish & Documentation (COMPLETE ✅)
**Files Created** (3):
- `src/Griddy/README.md` — Comprehensive API documentation and quick start guide
- `src/Griddy/EXAMPLES.md` — TypeScript examples for all major features
- `src/Griddy/THEME.md` — Theming guide with CSS variables
**Documentation Coverage**:
- ✅ API reference with all props documented
- ✅ Keyboard shortcuts table
- ✅ 10+ code examples (basic, editing, filtering, pagination, server-side)
- ✅ TypeScript integration patterns
- ✅ Theme system with dark mode, high contrast, brand themes
- ✅ Performance notes (10k+ rows, 60fps)
- ✅ Accessibility (ARIA, keyboard navigation)
- ✅ Browser support
**Storybook Stories** (24 total):
- Basic, LargeDataset
- SingleSelection, MultiSelection, LargeMultiSelection
- WithSearch, KeyboardNavigation
- WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithDateFiltering, WithAllFilterTypes, LargeDatasetWithFiltering
- ServerSideFilteringSorting
- WithInlineEditing
- WithClientSidePagination, WithServerSidePagination
- WithToolbar
- **NEW**: WithInfiniteScroll, WithColumnPinning, WithHeaderGrouping, WithDataGrouping, WithColumnReordering
**Implementation Complete**: All 9 phases + Phase 7.5 (Infinite Scroll) + Phase 8 remaining features finished!
## Resume Instructions (When Returning)
1. **Run full build check**:
```bash
pnpm run typecheck && pnpm run lint && pnpm run build
```
2. **Start Storybook to verify Phase 5**:
```bash
pnpm run storybook
# Open http://localhost:6006
# Check stories: WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithAllFilterTypes, LargeDatasetWithFiltering
```
3. **Run Playwright tests** (requires Storybook running in another terminal):
```bash
pnpm exec playwright test
```
4. **Current Status**: All phases 1-9 complete, plus infinite scroll and all Phase 8 advanced features
---
## Latest Completions (Phase 7.5 & Phase 8 Completion)
### Phase 7.5 - Infinite Scroll (COMPLETE ✅)
**Files Modified**:
- `src/Griddy/core/types.ts` — Added InfiniteScrollConfig interface
- `src/Griddy/core/GriddyStore.ts` — Added infiniteScroll to store state
- `src/Griddy/rendering/VirtualBody.tsx` — Scroll detection logic with threshold
- `src/Griddy/styles/griddy.module.css` — Loading indicator styles
- `src/Griddy/Griddy.stories.tsx` — Added WithInfiniteScroll story
**Features**:
- Threshold-based loading (default 10 rows from end)
- onLoadMore callback with async support
- Loading indicator with spinner animation
- hasMore flag to stop loading
- isLoading state for loading UI
### Phase 8 - Column Pinning (COMPLETE ✅)
**Files Modified**:
- `src/Griddy/core/types.ts` — Added columnPinning and onColumnPinningChange props
- `src/Griddy/core/GriddyStore.ts` — Added column pinning to store
- `src/Griddy/core/Griddy.tsx` — Integrated TanStack Table columnPinning state
- `src/Griddy/core/columnMapper.ts` — Enabled enablePinning on columns
- `src/Griddy/rendering/TableHeader.tsx` — Sticky positioning for pinned headers
- `src/Griddy/rendering/TableCell.tsx` — Sticky positioning for pinned cells
- `src/Griddy/styles/griddy.module.css` — Pinned column styles with shadows
- `src/Griddy/Griddy.stories.tsx` — Added WithColumnPinning story
**Features**:
- Left and right column pinning
- Sticky positioning with z-index layering
- Visual shadows on pinned columns
- Auto-builds initial state from column `pinned` property
- Controlled via columnPinning prop
### Phase 8 - Header Grouping (COMPLETE ✅)
**Files Modified**:
- `src/Griddy/core/columnMapper.ts` — Group columns by headerGroup property
- `src/Griddy/Griddy.stories.tsx` — Added WithHeaderGrouping story
**Features**:
- Multi-level column headers
- Groups defined by `headerGroup` property
- Automatic parent header creation
- TanStack Table getHeaderGroups() integration
### Phase 8 - Data Grouping (COMPLETE ✅)
**Files Modified**:
- `src/Griddy/core/types.ts` — Added groupable and aggregationFn to GriddyColumn
- `src/Griddy/core/Griddy.tsx` — Integrated getGroupedRowModel() and grouping state
- `src/Griddy/core/columnMapper.ts` — Enable grouping for groupable columns
- `src/Griddy/rendering/TableRow.tsx` — Detect grouped rows
- `src/Griddy/rendering/TableCell.tsx` — Expand/collapse UI and aggregated rendering
- `src/Griddy/styles/griddy.module.css` — Grouped row styling
- `src/Griddy/Griddy.stories.tsx` — Added WithDataGrouping story
**Features**:
- Group by column values
- Expand/collapse groups with arrow indicators
- Aggregation functions (sum, mean, count, min, max, etc.)
- Group row count display
- Nested grouping support
### Phase 8 - Column Reordering (COMPLETE ✅)
**Files Modified**:
- `src/Griddy/rendering/TableHeader.tsx` — HTML5 drag-and-drop handlers
- `src/Griddy/styles/griddy.module.css` — Drag cursor styles
- `src/Griddy/Griddy.stories.tsx` — Added WithColumnReordering story
**Features**:
- Drag-and-drop column headers
- Visual feedback (opacity, cursor)
- TanStack Table columnOrder integration
- Prevents reordering selection and pinned columns
- Smooth reordering with native drag events
---
## Phase 10: Future Enhancements
See `plan.md` for comprehensive list of 50+ planned features organized by category:
- Data & State Management (6 items)
- Advanced Data Features (5 items)
- Editing Enhancements (4 items)
- Filtering & Search (4 items)
- Export & Import (4 items)
- UI/UX Improvements (6 items)
- Performance & Optimization (5 items)
- Accessibility & Testing (5 items)
- Developer Experience (6 items)
- Advanced Features (11 items)
**High Priority Next**: Column layout persistence, validation system, loading states UI, tab-to-next-editable-cell

View File

@@ -2,11 +2,14 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ColumnFiltersState, RowSelectionState, SortingState } from '@tanstack/react-table'
import { Box } from '@mantine/core'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { GriddyColumn, GriddyProps } from './core/types'
import { Griddy } from './core/Griddy'
import { BadgeRenderer } from './features/renderers/BadgeRenderer'
import { ProgressBarRenderer } from './features/renderers/ProgressBarRenderer'
import { SparklineRenderer } from './features/renderers/SparklineRenderer'
// ─── Sample Data ─────────────────────────────────────────────────────────────
@@ -1194,3 +1197,309 @@ export const WithColumnReordering: Story = {
)
},
}
/** Error boundary - catches render errors and shows fallback UI */
export const WithErrorBoundary: Story = {
render: () => {
const [shouldError, setShouldError] = useState(false)
// Use a ref so the ErrorColumn always reads the latest value synchronously
const shouldErrorRef = useRef(false)
shouldErrorRef.current = shouldError
const ErrorColumn = () => {
if (shouldErrorRef.current) throw new Error('Intentional render error for testing')
return <span>OK</span>
}
const errorColumns: GriddyColumn<Person>[] = [
{ accessor: 'id', header: 'ID', id: 'id', width: 60 },
{ accessor: 'firstName', header: 'First Name', id: 'firstName', width: 120 },
{
accessor: () => null,
header: 'Status',
id: 'status',
renderer: () => <ErrorColumn />,
width: 100,
},
]
return (
<Box h="100%" mih="500px" w="100%">
<Box mb="sm" p="xs" style={{ background: '#fff5f5', border: '1px solid #ffc9c9', borderRadius: 4, fontSize: 13 }}>
<strong>Error Boundary:</strong> Click the button below to trigger an error. The error boundary catches it and shows a retry button.
<div style={{ marginTop: 8 }}>
<button onClick={() => setShouldError(true)} type="button">
Trigger Error
</button>
</div>
</Box>
<Griddy<Person>
columns={errorColumns}
data={smallData}
getRowId={(row) => String(row.id)}
height={400}
onError={(err) => console.log('Grid error:', err.message)}
onRetry={() => { shouldErrorRef.current = false; setShouldError(false) }}
/>
</Box>
)
},
}
/** Loading states - skeleton rows and overlay spinner */
export const WithLoadingStates: Story = {
render: () => {
const [data, setData] = useState<Person[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const timer = setTimeout(() => {
setData(smallData)
setIsLoading(false)
}, 3000)
return () => clearTimeout(timer)
}, [])
const handleReload = () => {
setIsLoading(true)
setData([])
setTimeout(() => {
setData(smallData)
setIsLoading(false)
}, 2000)
}
return (
<Box h="100%" mih="500px" w="100%">
<Box mb="sm" p="xs" style={{ background: '#e7f5ff', border: '1px solid #339af0', borderRadius: 4, fontSize: 13 }}>
<strong>Loading States:</strong> Shows skeleton when loading with no data, overlay when loading with existing data.
<div style={{ marginTop: 8 }}>
<button onClick={handleReload} type="button">Reload Data</button>
</div>
</Box>
<Griddy<Person>
columns={columns}
data={data}
getRowId={(row) => String(row.id)}
height={500}
isLoading={isLoading}
/>
</Box>
)
},
}
/** Custom cell renderers - progress bars, badges, sparklines */
export const WithCustomRenderers: Story = {
render: () => {
interface RendererData {
department: string
id: number
name: string
performance: number
trend: number[]
}
const rendererData: RendererData[] = Array.from({ length: 15 }, (_, i) => ({
department: departments[i % departments.length],
id: i + 1,
name: firstNames[i % firstNames.length],
performance: 20 + Math.round(Math.random() * 80),
trend: Array.from({ length: 7 }, () => Math.round(Math.random() * 100)),
}))
const rendererColumns: GriddyColumn<RendererData>[] = [
{ accessor: 'id', header: 'ID', id: 'id', width: 60 },
{ accessor: 'name', header: 'Name', id: 'name', width: 120 },
{
accessor: 'department',
header: 'Department',
id: 'department',
renderer: BadgeRenderer,
rendererMeta: {
colorMap: {
Design: '#ae3ec9',
Engineering: '#228be6',
Finance: '#40c057',
HR: '#fab005',
Marketing: '#fd7e14',
Sales: '#fa5252',
},
defaultColor: '#868e96',
},
width: 140,
},
{
accessor: 'performance',
header: 'Performance',
id: 'performance',
renderer: ProgressBarRenderer,
rendererMeta: { max: 100, showLabel: true },
width: 160,
},
{
accessor: 'trend',
header: 'Trend',
id: 'trend',
renderer: SparklineRenderer,
rendererMeta: { color: '#228be6', height: 24, width: 80 },
width: 120,
},
]
return (
<Box h="100%" mih="500px" w="100%">
<Box mb="sm" p="xs" style={{ background: '#d3f9d8', border: '1px solid #51cf66', borderRadius: 4, fontSize: 13 }}>
<strong>Custom Renderers:</strong> Badge (department), Progress Bar (performance), Sparkline (trend).
</Box>
<Griddy<RendererData>
columns={rendererColumns}
data={rendererData}
getRowId={(row) => String(row.id)}
height={500}
/>
</Box>
)
},
}
/** Quick filters - checkbox list of unique values in filter popover */
export const WithQuickFilters: Story = {
render: () => {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const quickFilterColumns: GriddyColumn<Person>[] = [
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
{ accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 },
{ accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
{
accessor: 'department',
filterable: true,
filterConfig: { quickFilter: true, type: 'text' },
header: 'Department',
id: 'department',
sortable: true,
width: 150,
},
{ accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
]
return (
<Box h="100%" mih="500px" w="100%">
<Box mb="sm" p="xs" style={{ background: '#e7f5ff', border: '1px solid #339af0', borderRadius: 4, fontSize: 13 }}>
<strong>Quick Filters:</strong> Click the filter icon on "Department" to see a checkbox list of unique values.
</Box>
<Griddy<Person>
columnFilters={filters}
columns={quickFilterColumns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
onColumnFiltersChange={setFilters}
/>
</Box>
)
},
}
/** Advanced search panel - multi-condition search with boolean operators */
export const WithAdvancedSearch: Story = {
render: () => {
return (
<Box h="100%" mih="600px" w="100%">
<Box mb="sm" p="xs" style={{ background: '#fff3cd', border: '1px solid #ffc107', borderRadius: 4, fontSize: 13 }}>
<strong>Advanced Search:</strong> Use the search panel to add multiple conditions with AND/OR/NOT operators.
</Box>
<Griddy<Person>
advancedSearch={{ enabled: true }}
columns={columns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
/>
</Box>
)
},
}
/** Filter presets - save and load filter configurations */
export const WithFilterPresets: Story = {
render: () => {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const filterColumns: GriddyColumn<Person>[] = [
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
{
accessor: 'firstName',
filterable: true,
filterConfig: { type: 'text' },
header: 'First Name',
id: 'firstName',
sortable: true,
width: 120,
},
{ accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
{
accessor: 'department',
filterable: true,
filterConfig: {
enumOptions: departments.map((d) => ({ label: d, value: d })),
type: 'enum',
},
header: 'Department',
id: 'department',
sortable: true,
width: 130,
},
{
accessor: 'age',
filterable: true,
filterConfig: { type: 'number' },
header: 'Age',
id: 'age',
sortable: true,
width: 70,
},
]
return (
<Box h="100%" mih="600px" w="100%">
<Box mb="sm" p="xs" style={{ background: '#d3f9d8', border: '1px solid #51cf66', borderRadius: 4, fontSize: 13 }}>
<strong>Filter Presets:</strong> Apply filters, then click the bookmark icon in the toolbar to save/load presets. Persists to localStorage.
</Box>
<Griddy<Person>
columnFilters={filters}
columns={filterColumns}
data={smallData}
filterPresets
getRowId={(row) => String(row.id)}
height={500}
onColumnFiltersChange={setFilters}
persistenceKey="storybook-presets"
showToolbar
/>
</Box>
)
},
}
/** Search history - recent searches persisted and selectable */
export const WithSearchHistory: Story = {
render: () => {
return (
<Box h="100%" mih="500px" w="100%">
<Box mb="sm" p="xs" style={{ background: '#e7f5ff', border: '1px solid #339af0', borderRadius: 4, fontSize: 13 }}>
<strong>Search History:</strong> Press Ctrl+F to search. Previous searches are saved and shown when you focus the search input.
</Box>
<Griddy<Person>
columns={columns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
persistenceKey="storybook-search-history"
search={{ enabled: true, placeholder: 'Search (history enabled)...' }}
/>
</Box>
)
},
}

View File

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

View File

@@ -20,7 +20,10 @@ import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, u
import type { GriddyProps, GriddyRef } from './types'
import { AdvancedSearchPanel, advancedSearchGlobalFilterFn } from '../features/advancedSearch'
import { GriddyErrorBoundary } from '../features/errorBoundary'
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation'
import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading'
import { PaginationControl } from '../features/pagination'
import { SearchOverlay } from '../features/search/SearchOverlay'
import { GridToolbar } from '../features/toolbar'
@@ -37,7 +40,9 @@ import { GriddyProvider, useGriddyStore } from './GriddyStore'
function _Griddy<T>(props: GriddyProps<T>, ref: Ref<GriddyRef<T>>) {
return (
<GriddyProvider {...props}>
<GriddyInner tableRef={ref} />
<GriddyErrorBoundary onError={props.onError} onRetry={props.onRetry}>
<GriddyInner tableRef={ref} />
</GriddyErrorBoundary>
{props.children}
</GriddyProvider>
)
@@ -70,6 +75,10 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
const className = useGriddyStore((s) => s.className)
const showToolbar = useGriddyStore((s) => s.showToolbar)
const exportFilename = useGriddyStore((s) => s.exportFilename)
const isLoading = useGriddyStore((s) => s.isLoading)
const filterPresets = useGriddyStore((s) => s.filterPresets)
const advancedSearch = useGriddyStore((s) => s.advancedSearch)
const persistenceKey = useGriddyStore((s) => s.persistenceKey)
const manualSorting = useGriddyStore((s) => s.manualSorting)
const manualFiltering = useGriddyStore((s) => s.manualFiltering)
const dataCount = useGriddyStore((s) => s.dataCount)
@@ -164,6 +173,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
enableRowSelection,
enableSorting: true,
getCoreRowModel: getCoreRowModel(),
...(advancedSearch?.enabled ? { globalFilterFn: advancedSearchGlobalFilterFn as any } : {}),
getExpandedRowModel: getExpandedRowModel(),
getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(),
getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined,
@@ -289,9 +299,12 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
role="grid"
>
{search?.enabled && <SearchOverlay />}
{advancedSearch?.enabled && <AdvancedSearchPanel table={table} />}
{showToolbar && (
<GridToolbar
exportFilename={exportFilename}
filterPresets={filterPresets}
persistenceKey={persistenceKey}
table={table}
/>
)}
@@ -302,7 +315,14 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
tabIndex={enableKeyboard ? 0 : undefined}
>
<TableHeader />
<VirtualBody />
{isLoading && (!data || data.length === 0) ? (
<GriddyLoadingSkeleton />
) : (
<>
<VirtualBody />
{isLoading && <GriddyLoadingOverlay />}
</>
)}
</div>
{paginationConfig?.enabled && (
<PaginationControl

View File

@@ -4,7 +4,7 @@ import type { Virtualizer } from '@tanstack/react-virtual'
import { createSyncStore } from '@warkypublic/zustandsyncstore'
import type { DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingConfig, InfiniteScrollConfig, PaginationConfig, SearchConfig, SelectionConfig } from './types'
import type { AdvancedSearchConfig, DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingConfig, InfiniteScrollConfig, PaginationConfig, SearchConfig, SelectionConfig } from './types'
// ─── Store State ─────────────────────────────────────────────────────────────
@@ -18,24 +18,30 @@ export interface GriddyStoreState extends GriddyUIState {
// ─── Internal refs (set imperatively) ───
_table: null | Table<any>
_virtualizer: null | Virtualizer<HTMLDivElement, Element>
advancedSearch?: AdvancedSearchConfig
className?: string
columnFilters?: ColumnFiltersState
columns?: GriddyColumn<any>[]
columnPinning?: ColumnPinningState
onColumnPinningChange?: (pinning: ColumnPinningState) => void
data?: any[]
// ─── Error State ───
error: Error | null
exportFilename?: string
dataAdapter?: DataAdapter<any>
dataCount?: number
filterPresets?: boolean
getRowId?: (row: any, index: number) => string
grouping?: GroupingConfig
height?: number | string
infiniteScroll?: InfiniteScrollConfig
isLoading?: boolean
keyboardNavigation?: boolean
manualFiltering?: boolean
manualSorting?: boolean
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
onError?: (error: Error) => void
onRowSelectionChange?: (selection: RowSelectionState) => void
onSortingChange?: (sorting: SortingState) => void
overscan?: number
@@ -46,6 +52,7 @@ export interface GriddyStoreState extends GriddyUIState {
search?: SearchConfig
selection?: SelectionConfig
setError: (error: Error | null) => void
showToolbar?: boolean
setScrollRef: (el: HTMLDivElement | null) => void
// ─── Internal ref setters ───
@@ -69,6 +76,7 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
_table: null,
_virtualizer: null,
error: null,
focusedColumnId: null,
// ─── Focus State ───
focusedRowIndex: null,
@@ -92,6 +100,7 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
},
moveFocusToStart: () => set({ focusedRowIndex: 0 }),
setEditing: (editing) => set({ isEditing: editing }),
setError: (error) => set({ error }),
setFocusedColumn: (id) => set({ focusedColumnId: id }),
// ─── Actions ───
setFocusedRow: (index) => set({ focusedRowIndex: index }),

View File

@@ -52,6 +52,18 @@ function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> {
} else if (col.filterable) {
def.filterFn = createOperatorFilter()
}
if (col.renderer) {
const renderer = col.renderer
def.cell = (info) => renderer({
column: col,
columnIndex: info.cell.column.getIndex(),
row: info.row.original,
rowIndex: info.row.index,
value: info.getValue(),
})
}
return def
}

View File

@@ -61,6 +61,8 @@ export interface GriddyColumn<T> {
minWidth?: number
pinned?: 'left' | 'right'
renderer?: CellRenderer<T>
/** Metadata passed to custom renderers (ProgressBar, Badge, Image, Sparkline) */
rendererMeta?: Record<string, unknown>
searchable?: boolean
sortable?: boolean
sortFn?: SortingFn<T>
@@ -79,7 +81,13 @@ export interface GriddyDataSource<T> {
// ─── Pagination ──────────────────────────────────────────────────────────────
export interface AdvancedSearchConfig {
enabled: boolean
}
export interface GriddyProps<T> {
// ─── Advanced Search ───
advancedSearch?: AdvancedSearchConfig
// ─── Children (adapters, etc.) ───
children?: ReactNode
// ─── Styling ───
@@ -89,6 +97,9 @@ export interface GriddyProps<T> {
showToolbar?: boolean
/** Export filename. Default: 'export.csv' */
exportFilename?: string
// ─── Filter Presets ───
/** Enable filter presets save/load in toolbar. Default: false */
filterPresets?: boolean
// ─── Filtering ───
/** Controlled column filters state */
columnFilters?: ColumnFiltersState
@@ -112,6 +123,9 @@ export interface GriddyProps<T> {
/** Container height */
height?: number | string
// ─── Loading ───
/** Show loading skeleton/overlay. Default: false */
isLoading?: boolean
// ─── Infinite Scroll ───
/** Infinite scroll configuration */
infiniteScroll?: InfiniteScrollConfig
@@ -126,6 +140,11 @@ export interface GriddyProps<T> {
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
// ─── Editing ───
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
// ─── Error Handling ───
/** Callback when a render error is caught by the error boundary */
onError?: (error: Error) => void
/** Callback before the error boundary retries rendering */
onRetry?: () => void
/** Selection change callback */
onRowSelectionChange?: (selection: RowSelectionState) => void

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import type { Column } from '@tanstack/react-table'
import { Button, Group, Popover, Stack, Text } from '@mantine/core'
import type React from 'react'
import { useState } from 'react'
import type { FilterConfig, FilterValue } from './types'
import { getGriddyColumn } from '../../core/columnMapper'
import { QuickFilterDropdown } from '../quickFilter'
import { ColumnFilterButton } from './ColumnFilterButton'
import { FilterBoolean } from './FilterBoolean'
import { FilterDate } from './FilterDate'
@@ -62,10 +64,15 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
const operators =
filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type]
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation()
setOpened(!opened)
}
return (
<Popover onChange={setOpened} onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
<Popover onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
<Popover.Target>
<ColumnFilterButton column={column} />
<ColumnFilterButton column={column} onClick={handleToggle} />
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={280}>
@@ -112,6 +119,18 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
/>
)}
{filterConfig.quickFilter && (
<QuickFilterDropdown
column={column}
onApply={(val) => {
setLocalValue(val)
column.setFilterValue(val)
setOpened(false)
}}
value={localValue}
/>
)}
<Group justify="flex-end">
<Button onClick={handleClear} size="xs" variant="subtle">
Clear

View File

@@ -3,6 +3,8 @@
export interface FilterConfig {
enumOptions?: FilterEnumOption[]
operators?: FilterOperator[]
/** Enable quick filter (checkbox list of unique values) in the filter popover */
quickFilter?: boolean
type: 'boolean' | 'date' | 'enum' | 'number' | 'text'
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,12 @@ import { IconDownload } from '@tabler/icons-react'
import { ColumnVisibilityMenu } from '../columnVisibility'
import { exportToCsv } from '../export'
import { FilterPresetsMenu } from '../filterPresets'
interface GridToolbarProps<T> {
exportFilename?: string
filterPresets?: boolean
persistenceKey?: string
showColumnToggle?: boolean
showExport?: boolean
table: Table<T>
@@ -15,6 +18,8 @@ interface GridToolbarProps<T> {
export function GridToolbar<T>({
exportFilename = 'export.csv',
filterPresets = false,
persistenceKey,
showColumnToggle = true,
showExport = true,
table,
@@ -23,12 +28,15 @@ export function GridToolbar<T>({
exportToCsv(table, exportFilename)
}
if (!showExport && !showColumnToggle) {
if (!showExport && !showColumnToggle && !filterPresets) {
return null
}
return (
<Group gap="xs" justify="flex-end" p="xs" style={{ borderBottom: '1px solid #e0e0e0' }}>
{filterPresets && (
<FilterPresetsMenu persistenceKey={persistenceKey} table={table} />
)}
{showExport && (
<ActionIcon
aria-label="Export to CSV"

View File

@@ -4,6 +4,7 @@ export { Griddy } from './core/Griddy'
export { GriddyProvider, useGriddyStore } from './core/GriddyStore'
export type { GriddyStoreState } from './core/GriddyStore'
export type {
AdvancedSearchConfig,
CellRenderer,
DataAdapter,
EditorComponent,
@@ -20,3 +21,11 @@ export type {
SearchConfig,
SelectionConfig,
} from './core/types'
// Feature exports
export { GriddyErrorBoundary } from './features/errorBoundary'
export { GriddyLoadingOverlay, GriddyLoadingSkeleton } from './features/loading'
export { BadgeRenderer, ImageRenderer, ProgressBarRenderer, SparklineRenderer } from './features/renderers'
export { FilterPresetsMenu, useFilterPresets } from './features/filterPresets'
export type { FilterPreset } from './features/filterPresets'
export { useSearchHistory } from './features/searchHistory'

View File

@@ -1055,10 +1055,10 @@ persist={{
- [x] Column hiding/visibility (TanStack `columnVisibility`) - COMPLETE
- [x] Export to CSV - COMPLETE
- [x] Toolbar component (column visibility + export) - COMPLETE
- [ ] Column pinning via TanStack Table `columnPinning` (deferred)
- [ ] Header grouping via TanStack Table `getHeaderGroups()` (deferred)
- [ ] Data grouping via TanStack Table `getGroupedRowModel()` (deferred)
- [ ] Column reordering (drag-and-drop + TanStack `columnOrder`) (deferred)
- [x] Column pinning via TanStack Table `columnPinning`
- [x] Header grouping via TanStack Table `getHeaderGroups()`
- [x] Data grouping via TanStack Table `getGroupedRowModel()`
- [x] Column reordering (drag-and-drop + TanStack `columnOrder`)
**Deliverable**: Advanced table features - PARTIAL ✅ (core features complete)
@@ -1202,8 +1202,8 @@ The grid follows WAI-ARIA grid pattern:
- [ ] **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 for data fetch failures
- [ ] **Loading states UI** - Skeleton loaders and shimmer effects during data fetch
- [x] **Error boundary** - Graceful error handling with retry (GriddyErrorBoundary, onError/onRetry props) ✅
- [x] **Loading states UI** - Skeleton rows with shimmer + translucent overlay spinner (isLoading prop) ✅
### Advanced Data Features
- [ ] **Tree/hierarchical data** - Parent-child rows with expand/collapse (nested data structures)
@@ -1216,13 +1216,13 @@ The grid follows WAI-ARIA grid pattern:
- [ ] **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** - Support for charts, progress bars, badges, images
- [x] **Custom cell renderers** - ProgressBar, Badge, Image, Sparkline renderers via `renderer` + `rendererMeta`
### Filtering & Search
- [ ] **Quick filters** - Dropdown filters in headers (Excel-style column filters)
- [ ] **Advanced search** - Multi-column search with boolean operators
- [ ] **Filter presets** - Save and load filter combinations
- [ ] **Search history** - Recent searches dropdown
- [x] **Quick filters** - Checkbox list of unique values in filter popover (`filterConfig.quickFilter: true`) ✅
- [x] **Advanced search** - Multi-condition search with AND/OR/NOT operators (AdvancedSearchPanel) ✅
- [x] **Filter presets** - Save/load/delete named filter presets to localStorage (FilterPresetsMenu) ✅
- [x] **Search history** - Recent searches dropdown with localStorage persistence (SearchHistoryDropdown) ✅
### Export & Import
- [ ] **Export to CSV/Excel** - Download current view with filters/sorts applied (load all data)
@@ -1248,7 +1248,7 @@ The grid follows WAI-ARIA grid pattern:
### Accessibility & Testing
- [ ] **Accessibility improvements** - Enhanced ARIA roles, screen reader announcements
- [ ] **Accessibility audit** - WCAG 2.1 AA compliance verification
- [ ] **E2E test suite** - Playwright tests for all features (expand from current filtering tests)
- [x] **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
@@ -1274,36 +1274,39 @@ The grid follows WAI-ARIA grid pattern:
## Implementation Priority
**High Priority** (Next phase):
**High Priority** (Next):
1. Column layout persistence
2. Validation system for editors
3. Loading states UI
4. Tab-to-next-editable-cell navigation
5. Context menu enhancements
3. Tab-to-next-editable-cell navigation
4. Context menu enhancements
**Medium Priority**:
1. Tree/hierarchical data
2. Master-detail rows
3. Export to CSV/Excel (enhanced)
4. Quick filters (Excel-style)
5. Keyboard shortcuts help overlay
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. Advanced search
5. Real-time collaboration
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)
## Next Steps
1. ✅ All Phase 1-9 features complete
2. ✅ Infinite scroll implemented
3. ✅ Column pinning implemented
4. ✅ Header grouping implemented
5. ✅ Data grouping implemented
6. ✅ Column reordering implemented
7. Choose Phase 10 features to implement based on user needs
8. Update main package README with Griddy documentation
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

View File

@@ -68,7 +68,7 @@ export function TableHeader() {
const isFilterPopoverOpen = filterPopoverOpen === header.column.id
const isPinned = header.column.getIsPinned()
const leftOffset = isPinned === 'left' ? header.getStart('left') : undefined
const rightOffset = isPinned === 'right' ? header.getAfter('right') : undefined
const rightOffset = isPinned === 'right' ? header.column.getAfter('right') : undefined
const isDragging = draggedColumn === header.column.id
const canReorder = !isSelectionCol && !isPinned

View File

@@ -334,3 +334,199 @@
.griddy-header-cell[draggable="true"]:active {
cursor: grabbing;
}
/* ─── Error Boundary ──────────────────────────────────────────────────── */
.griddy-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
gap: 12px;
min-height: 200px;
background: #fff5f5;
border: 1px solid #ffc9c9;
border-radius: 4px;
}
.griddy-error-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #ff6b6b;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
}
.griddy-error-message {
font-size: 16px;
font-weight: 600;
color: #c92a2a;
}
.griddy-error-detail {
font-size: 13px;
color: #868e96;
max-width: 400px;
text-align: center;
word-break: break-word;
}
.griddy-error-retry {
margin-top: 4px;
padding: 6px 16px;
font-size: 14px;
font-weight: 500;
color: #fff;
background: var(--griddy-focus-color, #228be6);
border: none;
border-radius: 4px;
cursor: pointer;
}
.griddy-error-retry:hover {
opacity: 0.9;
}
/* ─── Loading Skeleton ────────────────────────────────────────────────── */
.griddy-skeleton {
width: 100%;
}
.griddy-skeleton-row {
display: flex;
width: 100%;
border-bottom: 1px solid var(--griddy-border-color);
align-items: center;
}
.griddy-skeleton-cell {
padding: var(--griddy-cell-padding);
flex-shrink: 0;
overflow: hidden;
}
.griddy-skeleton-bar {
height: 14px;
width: 70%;
background: linear-gradient(90deg, #e9ecef 25%, #f1f3f5 50%, #e9ecef 75%);
background-size: 200% 100%;
animation: griddy-shimmer 1.5s ease-in-out infinite;
border-radius: 3px;
}
@keyframes griddy-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ─── Loading Overlay ─────────────────────────────────────────────────── */
.griddy-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
/* ─── Renderers ───────────────────────────────────────────────────────── */
.griddy-renderer-progress {
width: 100%;
height: 16px;
background: #e9ecef;
border-radius: 8px;
position: relative;
overflow: hidden;
}
.griddy-renderer-progress-bar {
height: 100%;
border-radius: 8px;
transition: width 0.3s ease;
}
.griddy-renderer-progress-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: #212529;
}
.griddy-renderer-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
color: #fff;
white-space: nowrap;
}
.griddy-renderer-image {
display: block;
border-radius: 4px;
object-fit: cover;
}
.griddy-renderer-sparkline {
display: block;
}
/* ─── Quick Filter ────────────────────────────────────────────────────── */
.griddy-quick-filter {
border-top: 1px solid var(--griddy-border-color);
padding-top: 8px;
margin-top: 4px;
}
/* ─── Advanced Search ─────────────────────────────────────────────────── */
.griddy-advanced-search {
padding: 8px 12px;
background: var(--griddy-header-bg);
border-bottom: 1px solid var(--griddy-border-color);
}
/* ─── Search History ──────────────────────────────────────────────────── */
.griddy-search-history {
margin-top: 4px;
padding: 4px 0;
border-top: 1px solid var(--griddy-border-color);
}
.griddy-search-history-item {
display: block;
width: 100%;
padding: 4px 8px;
font-size: 13px;
text-align: left;
background: none;
border: none;
cursor: pointer;
border-radius: 3px;
color: inherit;
}
.griddy-search-history-item:hover {
background: var(--griddy-row-hover-bg);
}

View File

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