diff --git a/package.json b/package.json index 724e91c..2c37a35 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,11 @@ "url": "git+https://git.warky.dev/wdevs/oranguru.git" }, "dependencies": { + "@mantine/dates": "^8.3.14", "@modelcontextprotocol/sdk": "^1.26.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", + "dayjs": "^1.11.19", "moment": "^2.30.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54c064d..576067e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@mantine/core': specifier: ^8.3.1 version: 8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mantine/dates': + specifier: ^8.3.14 + version: 8.3.14(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.1(react@19.2.4))(dayjs@1.11.19)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@mantine/hooks': specifier: ^8.3.1 version: 8.3.1(react@19.2.4) @@ -44,6 +47,9 @@ importers: '@warkypublic/zustandsyncstore': specifier: ^0.0.4 version: 0.0.4(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.13)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))) + dayjs: + specifier: ^1.11.19 + version: 1.11.19 idb-keyval: specifier: ^6.2.2 version: 6.2.2 @@ -753,6 +759,15 @@ packages: react: ^18.x || ^19.x react-dom: ^18.x || ^19.x + '@mantine/dates@8.3.14': + resolution: {integrity: sha512-NdStRo2ZQ55MoMF5B9vjhpBpHRDHF1XA9Dkb1kKSdNuLlaFXKlvoaZxj/3LfNPpn7Nqlns78nWt4X8/cgC2YIg==} + peerDependencies: + '@mantine/core': 8.3.14 + '@mantine/hooks': 8.3.14 + dayjs: '>=1.0.0' + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + '@mantine/hooks@8.3.1': resolution: {integrity: sha512-lQutBS+Q0iz/cNFvdrsYassPWo3RtWcmDGJeOtKfHigLzFOhxUuLOkQgepDbMf3WcVMB/tist6Px1PQOv57JTw==} peerDependencies: @@ -871,56 +886,67 @@ packages: resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.2': resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.2': resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.2': resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.50.2': resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.50.2': resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.50.2': resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.2': resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.2': resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.2': resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.2': resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.50.2': resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==} @@ -1090,24 +1116,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -1834,6 +1864,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -4658,6 +4691,15 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@mantine/dates@8.3.14(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.1(react@19.2.4))(dayjs@1.11.19)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@mantine/core': 8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mantine/hooks': 8.3.1(react@19.2.4) + clsx: 2.1.1 + dayjs: 1.11.19 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@mantine/hooks@8.3.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -5842,6 +5884,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.19: {} + de-indent@1.0.2: {} debug@4.4.3: diff --git a/src/Griddy/CONTEXT.md b/src/Griddy/CONTEXT.md index 3e5e6a0..e1c48f9 100644 --- a/src/Griddy/CONTEXT.md +++ b/src/Griddy/CONTEXT.md @@ -159,14 +159,37 @@ src/Griddy/features/filtering/ - Filter popover UI with operators - 6 Storybook stories with examples - 8 Playwright E2E test cases -- [ ] Phase 5.5: Date filtering (requires @mantine/dates) -- [ ] Phase 6: In-place editing -- [ ] Phase 7: Pagination + remote data adapters +- [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 ✅) - [ ] Phase 8: Grouping, pinning, column reorder, export - [ ] Phase 9: Polish, docs, tests ## 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) ## Build & Testing Status - [x] `pnpm run typecheck` — ✅ PASS (0 errors) @@ -197,17 +220,58 @@ pnpm exec playwright test --debug pnpm exec playwright show-report ``` -## Next Phase (Phase 5.5 - Date Filtering) +## Recent Completions -**Planned Tasks**: -1. Install `@mantine/dates` dependency -2. Create `FilterDate.tsx` component with date range picker -3. Add date operators: after, before, between, exactDate -4. Integrate into ColumnFilterPopover -5. Add date filtering Storybook story -6. Add Playwright E2E tests for date filtering +### Phase 5.5 - Date Filtering +**Files Created**: +- `src/Griddy/features/filtering/FilterDate.tsx` — Date picker with single/range modes -**Estimated Effort**: 1-2 hours +**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 + +**Next Phase**: Phase 8 (Grouping, Pinning, Export) or Phase 9 (Polish & Documentation) ## Resume Instructions (When Returning) diff --git a/src/Griddy/Griddy.stories.tsx b/src/Griddy/Griddy.stories.tsx index 40f371f..c634fa3 100644 --- a/src/Griddy/Griddy.stories.tsx +++ b/src/Griddy/Griddy.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { ColumnFiltersState, RowSelectionState } from '@tanstack/react-table' import { Box } from '@mantine/core' -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { GriddyColumn, GriddyProps } from './core/types' @@ -423,7 +423,51 @@ export const WithBooleanFiltering: Story = { }, } -/** Combined filtering - all filter types together */ +/** Date filtering with operators like is, isBefore, isAfter, isBetween */ +export const WithDateFiltering: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const filterColumns: GriddyColumn[] = [ + { 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: 'email', header: 'Email', id: 'email', sortable: true, width: 240 }, + { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 }, + { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 }, + { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { + accessor: 'startDate', + filterable: true, + filterConfig: { type: 'date' }, + header: 'Start Date', + id: 'startDate', + sortable: true, + width: 120, + }, + { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 }, + ] + + return ( + + + columnFilters={filters} + columns={filterColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + /> + + Active Filters: +
{JSON.stringify(filters, null, 2)}
+
+
+ ) + }, +} + +/** Combined filtering - all filter types together (text, number, enum, boolean, date) */ export const WithAllFilterTypes: Story = { render: () => { const [filters, setFilters] = useState([]) @@ -471,7 +515,15 @@ export const WithAllFilterTypes: Story = { width: 130, }, { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, - { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 }, + { + accessor: 'startDate', + filterable: true, + filterConfig: { type: 'date' }, + header: 'Start Date', + id: 'startDate', + sortable: true, + width: 120, + }, { accessor: 'active', filterable: true, @@ -550,7 +602,15 @@ export const LargeDatasetWithFiltering: Story = { width: 130, }, { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, - { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 }, + { + accessor: 'startDate', + filterable: true, + filterConfig: { type: 'date' }, + header: 'Start Date', + id: 'startDate', + sortable: true, + width: 120, + }, { accessor: 'active', filterable: true, @@ -580,3 +640,333 @@ export const LargeDatasetWithFiltering: Story = { ) }, } +/** Server-side filtering and sorting - data fetching handled externally */ +export const ServerSideFilteringSorting: Story = { + render: () => { + const [filters, setFilters] = useState([]) + const [sorting, setSorting] = useState([]) + const [serverData, setServerData] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(false) + + // Simulate server-side data fetching + useEffect(() => { + const fetchData = async () => { + setIsLoading(true) + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 300)) + + let filteredData = [...largeData] + + // Apply filters (simulating server-side filtering) + filters.forEach((filter) => { + const filterValue = filter.value as any + if (filterValue?.operator && filterValue?.value !== undefined) { + filteredData = filteredData.filter(row => { + const cellValue = row[filter.id as keyof Person] + switch (filterValue.operator) { + case 'contains': + return String(cellValue).toLowerCase().includes(String(filterValue.value).toLowerCase()) + case 'equals': + return cellValue === filterValue.value + case 'greaterThan': + return Number(cellValue) > Number(filterValue.value) + case 'lessThan': + return Number(cellValue) < Number(filterValue.value) + default: + return true + } + }) + } + }) + + // Apply sorting (simulating server-side sorting) + if (sorting.length > 0) { + const sort = sorting[0] + filteredData.sort((a, b) => { + const aVal = a[sort.id as keyof Person] + const bVal = b[sort.id as keyof Person] + if (aVal < bVal) return sort.desc ? 1 : -1 + if (aVal > bVal) return sort.desc ? -1 : 1 + return 0 + }) + } + + setServerData(filteredData) + setTotalCount(filteredData.length) + setIsLoading(false) + } + + fetchData() + }, [filters, sorting]) + + const filterColumns: GriddyColumn[] = [ + { 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', + filterable: true, + filterConfig: { type: 'text' }, + header: 'Last Name', + id: 'lastName', + sortable: true, + width: 120, + }, + { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 }, + { + accessor: 'age', + filterable: true, + filterConfig: { type: 'number' }, + header: 'Age', + id: 'age', + sortable: true, + width: 70, + }, + { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 }, + { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 }, + { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 }, + ] + + return ( + + + Server-Side Mode: Filtering and sorting are handled by simulated server. Data fetches on filter/sort change. + + + columnFilters={filters} + columns={filterColumns} + data={serverData} + dataCount={totalCount} + getRowId={(row) => String(row.id)} + height={500} + manualFiltering + manualSorting + onColumnFiltersChange={setFilters} + onSortingChange={setSorting} + sorting={sorting} + /> + + Server State: +
Loading: {isLoading ? 'true' : 'false'}
+
Total Count: {totalCount}
+
Displayed Rows: {serverData.length}
+ Active Filters: +
{JSON.stringify(filters, null, 2)}
+ Active Sorting: +
{JSON.stringify(sorting, null, 2)}
+
+
+ ) + }, +} + +/** Inline editing - double-click cell or Ctrl+E/Enter to edit */ +export const WithInlineEditing: Story = { + render: () => { + const [data, setData] = useState(smallData.map(p => ({ ...p }))) + + const handleEditCommit = async (rowId: string, columnId: string, value: unknown) => { + setData(prev => prev.map(row => + String(row.id) === rowId + ? { ...row, [columnId]: value } + : row + )) + } + + const editColumns: GriddyColumn[] = [ + { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, + { + accessor: 'firstName', + editable: true, + editorConfig: { type: 'text' }, + header: 'First Name', + id: 'firstName', + sortable: true, + width: 120, + }, + { + accessor: 'lastName', + editable: true, + editorConfig: { type: 'text' }, + header: 'Last Name', + id: 'lastName', + sortable: true, + width: 120, + }, + { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 }, + { + accessor: 'age', + editable: true, + editorConfig: { max: 100, min: 18, type: 'number' }, + header: 'Age', + id: 'age', + sortable: true, + width: 70, + }, + { + accessor: 'department', + editable: true, + editorConfig: { + options: departments.map(d => ({ label: d, value: d })), + type: 'select', + }, + header: 'Department', + id: 'department', + sortable: true, + width: 130, + }, + { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 }, + { + accessor: 'active', + editable: true, + editorConfig: { type: 'checkbox' }, + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ] + + return ( + + + Editing Mode: Double-click any editable cell (First Name, Last Name, Age, Department, Active) or press Ctrl+E/Enter when a row is focused. +
+ Keyboard: Enter commits, Escape cancels, Tab moves to next editable cell +
+
+ + columns={editColumns} + data={data} + getRowId={(row) => String(row.id)} + height={500} + onEditCommit={handleEditCommit} + /> + + Modified Data (first 3 rows): +
{JSON.stringify(data.slice(0, 3), null, 2)}
+
+
+ ) + }, +} + +/** Client-side pagination - paginate large datasets in memory */ +export const WithClientSidePagination: Story = { + render: () => { + const paginationColumns: GriddyColumn[] = [ + { 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: 'email', header: 'Email', id: 'email', sortable: true, width: 240 }, + { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 }, + { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 }, + { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 }, + { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 }, + ] + + return ( + + + Client-Side Pagination: 10,000 rows paginated in memory. Fast page switching, all data loaded upfront. + + + columns={paginationColumns} + data={largeData} + getRowId={(row) => String(row.id)} + height={500} + pagination={{ + enabled: true, + pageSize: 25, + pageSizeOptions: [10, 25, 50, 100], + }} + /> + + ) + }, +} + +/** Server-side pagination - fetch pages from server */ +export const WithServerSidePagination: Story = { + render: () => { + const [serverData, setServerData] = useState([]) + const [pageIndex, setPageIndex] = useState(0) + const [pageSize, setPageSize] = useState(25) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(false) + + // Simulate server-side pagination + useEffect(() => { + const fetchPage = async () => { + setIsLoading(true) + await new Promise(resolve => setTimeout(resolve, 300)) + + const start = pageIndex * pageSize + const end = start + pageSize + const page = largeData.slice(start, end) + + setServerData(page) + setTotalCount(largeData.length) + setIsLoading(false) + } + + fetchPage() + }, [pageIndex, pageSize]) + + const paginationColumns: GriddyColumn[] = [ + { 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: 'email', header: 'Email', id: 'email', sortable: true, width: 240 }, + { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 }, + { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 }, + { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 }, + { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 }, + ] + + return ( + + + Server-Side Pagination: Data fetched per page from simulated server. Only current page loaded. + + + columns={paginationColumns} + data={serverData} + dataCount={totalCount} + getRowId={(row) => String(row.id)} + height={500} + pagination={{ + enabled: true, + onPageChange: (page) => setPageIndex(page), + onPageSizeChange: (size) => { + setPageSize(size) + setPageIndex(0) + }, + pageSize, + pageSizeOptions: [10, 25, 50, 100], + }} + /> + + Server State: +
Loading: {isLoading ? 'true' : 'false'}
+
Current Page: {pageIndex + 1}
+
Page Size: {pageSize}
+
Total Rows: {totalCount}
+
Displayed Rows: {serverData.length}
+
+
+ ) + }, +} diff --git a/src/Griddy/core/Griddy.tsx b/src/Griddy/core/Griddy.tsx index 3a43ca7..99cb008 100644 --- a/src/Griddy/core/Griddy.tsx +++ b/src/Griddy/core/Griddy.tsx @@ -18,6 +18,7 @@ import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, u import type { GriddyProps, GriddyRef } from './types' import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation' +import { PaginationControl } from '../features/pagination' import { SearchOverlay } from '../features/search/SearchOverlay' import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer' import { TableHeader } from '../rendering/TableHeader' @@ -60,6 +61,9 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { const height = useGriddyStore((s) => s.height) const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation) const className = useGriddyStore((s) => s.className) + const manualSorting = useGriddyStore((s) => s.manualSorting) + const manualFiltering = useGriddyStore((s) => s.manualFiltering) + const dataCount = useGriddyStore((s) => s.dataCount) const setTable = useGriddyStore((s) => s.setTable) const setVirtualizer = useGriddyStore((s) => s.setVirtualizer) const setScrollRef = useGriddyStore((s) => s.setScrollRef) @@ -91,6 +95,23 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize, }) + // Wrap pagination setters to call callbacks + const handlePaginationChange = (updater: any) => { + setInternalPagination(prev => { + const next = typeof updater === 'function' ? updater(prev) : updater + // Call callbacks if pagination config exists + if (paginationConfig) { + if (next.pageIndex !== prev.pageIndex && paginationConfig.onPageChange) { + paginationConfig.onPageChange(next.pageIndex) + } + if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) { + paginationConfig.onPageSizeChange(next.pageSize) + } + } + return next + }) + } + // Resolve controlled vs uncontrolled const sorting = controlledSorting ?? internalSorting const setSorting = onSortingChange ?? setInternalSorting @@ -114,16 +135,19 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { enableRowSelection, enableSorting: true, getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), + getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(), getRowId: getRowId as any ?? ((_, index) => String(index)), - getSortedRowModel: getSortedRowModel(), + getSortedRowModel: manualSorting ? undefined : getSortedRowModel(), + manualFiltering: manualFiltering ?? false, + manualSorting: manualSorting ?? false, onColumnFiltersChange: setColumnFilters as any, onColumnOrderChange: setColumnOrder, onColumnVisibilityChange: setColumnVisibility, onGlobalFilterChange: setGlobalFilter, - onPaginationChange: paginationConfig?.enabled ? setInternalPagination : undefined, + onPaginationChange: paginationConfig?.enabled ? handlePaginationChange : undefined, onRowSelectionChange: setRowSelection as any, onSortingChange: setSorting as any, + rowCount: dataCount, state: { columnFilters, columnOrder, @@ -238,6 +262,12 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { + {paginationConfig?.enabled && ( + + )} ) } diff --git a/src/Griddy/core/GriddyStore.ts b/src/Griddy/core/GriddyStore.ts index f6f5ff1..e60755a 100644 --- a/src/Griddy/core/GriddyStore.ts +++ b/src/Griddy/core/GriddyStore.ts @@ -23,10 +23,13 @@ export interface GriddyStoreState extends GriddyUIState { columns?: GriddyColumn[] data?: any[] dataAdapter?: DataAdapter + dataCount?: number getRowId?: (row: any, index: number) => string grouping?: GroupingConfig height?: number | string keyboardNavigation?: boolean + manualFiltering?: boolean + manualSorting?: boolean onColumnFiltersChange?: (filters: ColumnFiltersState) => void onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise | void onRowSelectionChange?: (selection: RowSelectionState) => void diff --git a/src/Griddy/core/types.ts b/src/Griddy/core/types.ts index e879416..a0ee68e 100644 --- a/src/Griddy/core/types.ts +++ b/src/Griddy/core/types.ts @@ -2,6 +2,7 @@ import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningStat import type { Virtualizer } from '@tanstack/react-virtual' import type { ReactNode } from 'react' +import type { EditorConfig } from '../editors' import type { FilterConfig } from '../features/filtering' // ─── Column Definition ─────────────────────────────────────────────────────── @@ -46,6 +47,7 @@ export interface GriddyColumn { accessor: ((row: T) => unknown) | keyof T editable?: ((row: T) => boolean) | boolean editor?: EditorComponent + editorConfig?: EditorConfig filterable?: boolean filterConfig?: FilterConfig filterFn?: FilterFn @@ -91,6 +93,8 @@ export interface GriddyProps { // ─── Data Adapter ─── dataAdapter?: DataAdapter + /** Total row count (for server-side pagination/filtering). If provided, enables manual mode. */ + dataCount?: number /** Stable row identity function */ getRowId?: (row: T, index: number) => string // ─── Grouping ─── @@ -101,6 +105,10 @@ export interface GriddyProps { // ─── Keyboard ─── /** Enable keyboard navigation. Default: true */ keyboardNavigation?: boolean + /** Manual filtering mode - filtering handled externally (server-side). Default: false */ + manualFiltering?: boolean + /** Manual sorting mode - sorting handled externally (server-side). Default: false */ + manualSorting?: boolean onColumnFiltersChange?: (filters: ColumnFiltersState) => void // ─── Editing ─── diff --git a/src/Griddy/editors/CheckboxEditor.tsx b/src/Griddy/editors/CheckboxEditor.tsx new file mode 100644 index 0000000..df69ad1 --- /dev/null +++ b/src/Griddy/editors/CheckboxEditor.tsx @@ -0,0 +1,43 @@ +import { Checkbox } from '@mantine/core' +import { useEffect, useState } from 'react' + +import type { BaseEditorProps } from './types' + +export function CheckboxEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps) { + const [checked, setChecked] = useState(Boolean(value)) + + useEffect(() => { + setChecked(Boolean(value)) + }, [value]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + onCommit(checked) + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } else if (e.key === 'Tab') { + e.preventDefault() + onCommit(checked) + if (e.shiftKey) { + onMovePrev?.() + } else { + onMoveNext?.() + } + } else if (e.key === ' ') { + e.preventDefault() + setChecked(!checked) + } + } + + return ( + setChecked(e.currentTarget.checked)} + onKeyDown={handleKeyDown} + size="xs" + /> + ) +} diff --git a/src/Griddy/editors/DateEditor.tsx b/src/Griddy/editors/DateEditor.tsx new file mode 100644 index 0000000..d30625c --- /dev/null +++ b/src/Griddy/editors/DateEditor.tsx @@ -0,0 +1,46 @@ +import { DatePickerInput } from '@mantine/dates' +import { useEffect, useState } from 'react' + +import type { BaseEditorProps } from './types' + +export function DateEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps) { + const [dateValue, setDateValue] = useState(() => + value ? new Date(value) : null + ) + + useEffect(() => { + setDateValue(value ? new Date(value) : null) + }, [value]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + onCommit(dateValue ?? '') + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } else if (e.key === 'Tab') { + e.preventDefault() + onCommit(dateValue ?? '') + if (e.shiftKey) { + onMovePrev?.() + } else { + onMoveNext?.() + } + } + } + + return ( + { + const dateVal = date ? (typeof date === 'string' ? new Date(date) : date) : null + setDateValue(dateVal) + }} + onKeyDown={handleKeyDown} + size="xs" + value={dateValue} + /> + ) +} diff --git a/src/Griddy/editors/NumericEditor.tsx b/src/Griddy/editors/NumericEditor.tsx new file mode 100644 index 0000000..7a327a6 --- /dev/null +++ b/src/Griddy/editors/NumericEditor.tsx @@ -0,0 +1,49 @@ +import { NumberInput } from '@mantine/core' +import { useEffect, useState } from 'react' + +import type { BaseEditorProps } from './types' + +interface NumericEditorProps extends BaseEditorProps { + max?: number + min?: number + step?: number +} + +export function NumericEditor({ autoFocus = true, max, min, onCancel, onCommit, onMoveNext, onMovePrev, step = 1, value }: NumericEditorProps) { + const [inputValue, setInputValue] = useState(value ?? '') + + useEffect(() => { + setInputValue(value ?? '') + }, [value]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + onCommit(typeof inputValue === 'number' ? inputValue : Number(inputValue)) + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } else if (e.key === 'Tab') { + e.preventDefault() + onCommit(typeof inputValue === 'number' ? inputValue : Number(inputValue)) + if (e.shiftKey) { + onMovePrev?.() + } else { + onMoveNext?.() + } + } + } + + return ( + setInputValue(val ?? '')} + onKeyDown={handleKeyDown} + size="xs" + step={step} + value={inputValue} + /> + ) +} diff --git a/src/Griddy/editors/SelectEditor.tsx b/src/Griddy/editors/SelectEditor.tsx new file mode 100644 index 0000000..ccc60fa --- /dev/null +++ b/src/Griddy/editors/SelectEditor.tsx @@ -0,0 +1,49 @@ +import { Select } from '@mantine/core' +import { useEffect, useState } from 'react' + +import type { BaseEditorProps, SelectOption } from './types' + +interface SelectEditorProps extends BaseEditorProps { + options: SelectOption[] +} + +export function SelectEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, options, value }: SelectEditorProps) { + const [selectedValue, setSelectedValue] = useState(value != null ? String(value) : null) + + useEffect(() => { + setSelectedValue(value != null ? String(value) : null) + }, [value]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + // Find the actual value from options + const option = options.find(opt => String(opt.value) === selectedValue) + onCommit(option?.value ?? selectedValue) + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } else if (e.key === 'Tab') { + e.preventDefault() + const option = options.find(opt => String(opt.value) === selectedValue) + onCommit(option?.value ?? selectedValue) + if (e.shiftKey) { + onMovePrev?.() + } else { + onMoveNext?.() + } + } + } + + return ( + ({ label: op.label, value: op.id }))} + label="Operator" + onChange={handleOperatorChange} + searchable + size="xs" + value={operator} + /> + + { + const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null + setStartDate(dateValue) + onChange({ + endDate: endDate ?? undefined, + operator: 'isBetween', + startDate: dateValue ?? undefined, + }) + }} + placeholder="Start date" + size="xs" + value={startDate} + /> + { + const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null + setEndDate(dateValue) + onChange({ + endDate: dateValue ?? undefined, + operator: 'isBetween', + startDate: startDate ?? undefined, + }) + }} + placeholder="End date" + size="xs" + value={endDate} + /> + + + ) + } + + return ( + e.stopPropagation()}> + ({ label: String(size), value: String(size) }))} + onChange={(value) => { + if (value) { + table.setPageSize(Number(value)) + } + }} + size="xs" + value={String(pageSize)} + w={70} + /> + + + ) +} diff --git a/src/Griddy/features/pagination/index.ts b/src/Griddy/features/pagination/index.ts new file mode 100644 index 0000000..5209574 --- /dev/null +++ b/src/Griddy/features/pagination/index.ts @@ -0,0 +1 @@ +export { PaginationControl } from './PaginationControl' diff --git a/src/Griddy/plan.md b/src/Griddy/plan.md index f703b06..8726e6f 100644 --- a/src/Griddy/plan.md +++ b/src/Griddy/plan.md @@ -992,8 +992,8 @@ persist={{ - [x] Filter status indicators (blue/gray icons in headers) - [x] Debounced text input (300ms) - [x] Apply/Clear buttons for filter controls -- [ ] Date filtering (Phase 5.5 - requires @mantine/dates) -- [ ] Server-side sort/filter support (`manualSorting`, `manualFiltering`) +- [x] Date filtering (Phase 5.5 - COMPLETE with @mantine/dates) +- [x] Server-side sort/filter support (`manualSorting`, `manualFiltering`) - COMPLETE - [ ] Sort/filter state persistence **Deliverable**: Complete data manipulation features powered by TanStack Table @@ -1022,27 +1022,34 @@ persist={{ - `tests/e2e/filtering-context-menu.spec.ts` — 8 comprehensive E2E test cases ### Phase 6: In-Place Editing -- [ ] Implement `EditableCell.tsx` with editor mounting -- [ ] Implement built-in editors: Text, Numeric, Date, Select, Checkbox -- [ ] Keyboard editing: +- [x] Implement `EditableCell.tsx` with editor mounting +- [x] Implement built-in editors: Text, Numeric, Date, Select, Checkbox +- [x] Keyboard editing: - Ctrl+E or Enter to start editing - - Tab/Shift+Tab between editable cells - - Enter to commit + move to next row + - Tab/Shift+Tab between editable cells (partial - editors handle Tab) + - Enter to commit - Escape to cancel -- [ ] Validation system -- [ ] `onEditCommit` callback -- [ ] Undo/redo (optional) +- [x] `onEditCommit` callback +- [x] Double-click to edit +- [x] Editor types: text, number, date, select, checkbox +- [ ] Validation system (deferred) +- [ ] Tab to next editable cell navigation (deferred) +- [ ] Undo/redo (optional, deferred) -**Deliverable**: Full in-place editing with keyboard support +**Deliverable**: Full in-place editing with keyboard support - COMPLETE ✅ ### Phase 7: Pagination & Data Adapters -- [ ] Client-side pagination via TanStack Table `getPaginationRowModel()` -- [ ] Pagination controls UI (page nav, page size selector) -- [ ] Implement `RemoteServerAdapter` with cursor + offset support -- [ ] Loading states and error handling -- [ ] Infinite scroll pattern (optional) +- [x] Client-side pagination via TanStack Table `getPaginationRowModel()` +- [x] Pagination controls UI (page nav, page size selector) +- [x] Server-side pagination callbacks (`onPageChange`, `onPageSizeChange`) +- [x] Page navigation controls (first, previous, next, last) +- [x] Page size selector dropdown +- [x] Storybook stories (client-side + server-side) +- [ ] Implement `RemoteServerAdapter` with cursor + offset support (deferred - callbacks sufficient) +- [ ] Loading states UI (deferred - handled externally) +- [ ] Infinite scroll pattern (optional, deferred) -**Deliverable**: Pagination and remote data support +**Deliverable**: Pagination and remote data support - COMPLETE ✅ ### Phase 8: Advanced Features - [ ] Header grouping via TanStack Table `getHeaderGroups()` diff --git a/src/Griddy/rendering/EditableCell.tsx b/src/Griddy/rendering/EditableCell.tsx new file mode 100644 index 0000000..83d4017 --- /dev/null +++ b/src/Griddy/rendering/EditableCell.tsx @@ -0,0 +1,122 @@ +import type { Cell } from '@tanstack/react-table' + +import { useCallback, useEffect, useState } from 'react' + +import { CheckboxEditor, DateEditor, NumericEditor, SelectEditor, TextEditor } from '../editors' +import type { EditorConfig } from '../editors' +import { getGriddyColumn } from '../core/columnMapper' +import { useGriddyStore } from '../core/GriddyStore' + +interface EditableCellProps { + cell: Cell + isEditing: boolean + onCancelEdit: () => void + onCommitEdit: (value: unknown) => void + onMoveNext?: () => void + onMovePrev?: () => void +} + +export function EditableCell({ cell, isEditing, onCancelEdit, onCommitEdit, onMoveNext, onMovePrev }: EditableCellProps) { + const griddyColumn = getGriddyColumn(cell.column) + const editorConfig: EditorConfig = (griddyColumn as any)?.editorConfig ?? {} + const customEditor = (griddyColumn as any)?.editor + + const [value, setValue] = useState(cell.getValue()) + + useEffect(() => { + setValue(cell.getValue()) + }, [cell]) + + const handleCommit = useCallback((newValue: unknown) => { + setValue(newValue) + onCommitEdit(newValue) + }, [onCommitEdit]) + + const handleCancel = useCallback(() => { + setValue(cell.getValue()) + onCancelEdit() + }, [cell, onCancelEdit]) + + if (!isEditing) { + return null + } + + // Custom editor from column definition + if (customEditor) { + const EditorComponent = customEditor as any + return ( + + ) + } + + // Built-in editors based on editorConfig.type + const editorType = editorConfig.type ?? 'text' + + switch (editorType) { + case 'number': + return ( + + ) + + case 'date': + return ( + + ) + + case 'select': + return ( + + ) + + case 'checkbox': + return ( + + ) + + case 'text': + default: + return ( + + ) + } +} diff --git a/src/Griddy/rendering/TableCell.tsx b/src/Griddy/rendering/TableCell.tsx index 2a771df..608ef12 100644 --- a/src/Griddy/rendering/TableCell.tsx +++ b/src/Griddy/rendering/TableCell.tsx @@ -1,8 +1,11 @@ import { Checkbox } from '@mantine/core' import { type Cell, flexRender } from '@tanstack/react-table' +import { getGriddyColumn } from '../core/columnMapper' import { CSS, SELECTION_COLUMN_ID } from '../core/constants' +import { useGriddyStore } from '../core/GriddyStore' import styles from '../styles/griddy.module.css' +import { EditableCell } from './EditableCell' interface TableCellProps { cell: Cell @@ -10,18 +13,60 @@ interface TableCellProps { export function TableCell({ cell }: TableCellProps) { const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID + const isEditing = useGriddyStore((s) => s.isEditing) + const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex) + const focusedColumnId = useGriddyStore((s) => s.focusedColumnId) + const setEditing = useGriddyStore((s) => s.setEditing) + const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn) + const onEditCommit = useGriddyStore((s) => s.onEditCommit) if (isSelectionCol) { return } + const griddyColumn = getGriddyColumn(cell.column) + const rowIndex = cell.row.index + const columnId = cell.column.id + const isEditable = (griddyColumn as any)?.editable ?? false + const isFocusedCell = isEditing && focusedRowIndex === rowIndex && focusedColumnId === columnId + + const handleCommit = async (value: unknown) => { + if (onEditCommit) { + await onEditCommit(cell.row.id, columnId, value) + } + setEditing(false) + setFocusedColumn(null) + } + + const handleCancel = () => { + setEditing(false) + setFocusedColumn(null) + } + + const handleDoubleClick = () => { + if (isEditable) { + setEditing(true) + setFocusedColumn(columnId) + } + } + return (
- {flexRender(cell.column.columnDef.cell, cell.getContext())} + {isFocusedCell && isEditable ? ( + + ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )}
) } diff --git a/src/Griddy/styles/griddy.module.css b/src/Griddy/styles/griddy.module.css index 53a72f6..45de9d7 100644 --- a/src/Griddy/styles/griddy.module.css +++ b/src/Griddy/styles/griddy.module.css @@ -239,3 +239,10 @@ border-color: var(--griddy-focus-color); box-shadow: 0 0 0 2px rgba(34, 139, 230, 0.2); } + +/* ─── Pagination ───────────────────────────────────────────────────────── */ + +.griddy-pagination { + border-top: 1px solid var(--griddy-border-color); + background: var(--griddy-header-bg); +}