A Griddy AI prototype
This commit is contained in:
@@ -22,6 +22,7 @@ export const PreviewDecorator: Decorator = (Story, context) => {
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
|
||||
<ModalsProvider>
|
||||
{useGlobalStore ? (
|
||||
<GlobalStateStoreProvider fetchOnMount={false}>
|
||||
|
||||
102
src/Griddy/CONTEXT.md
Normal file
102
src/Griddy/CONTEXT.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 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.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two TanStack Libraries
|
||||
- **@tanstack/react-table** (headless table model): owns sorting, filtering, pagination, row selection, column visibility, grouping state
|
||||
- **@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`
|
||||
- `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)
|
||||
- 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>
|
||||
</GriddyProvider>
|
||||
</Griddy>
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| 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. |
|
||||
|
||||
## Keyboard Bindings
|
||||
- Arrow Up/Down: move focus
|
||||
- Page Up/Down: jump by visible page
|
||||
- Home/End: first/last row
|
||||
- Space: toggle selection
|
||||
- Shift+Arrow: extend multi-selection
|
||||
- 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
|
||||
|
||||
## UI Components
|
||||
Uses **Mantine** components (not raw HTML):
|
||||
- `Checkbox` from `@mantine/core` for row/header checkboxes
|
||||
- `TextInput` from `@mantine/core` for search input
|
||||
|
||||
## 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)
|
||||
- [ ] Phase 5: Column filtering UI
|
||||
- [ ] Phase 6: In-place editing
|
||||
- [ ] Phase 7: Pagination + remote data adapters
|
||||
- [ ] 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)
|
||||
|
||||
## Build
|
||||
- `pnpm run typecheck` — clean
|
||||
- `pnpm run build` — clean
|
||||
- `pnpm run storybook` — stories render correctly
|
||||
229
src/Griddy/Griddy.stories.tsx
Normal file
229
src/Griddy/Griddy.stories.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { RowSelectionState } from '@tanstack/react-table'
|
||||
|
||||
import { Box } from '@mantine/core'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { GriddyColumn, GriddyProps } from './core/types'
|
||||
|
||||
import { Griddy } from './core/Griddy'
|
||||
|
||||
// ─── Sample Data ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface Person {
|
||||
active: boolean
|
||||
age: number
|
||||
department: string
|
||||
email: string
|
||||
firstName: string
|
||||
id: number
|
||||
lastName: string
|
||||
salary: number
|
||||
startDate: string
|
||||
}
|
||||
|
||||
const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Design', 'Legal', 'Support']
|
||||
const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack', 'Karen', 'Leo', 'Mia', 'Nick', 'Olivia', 'Paul', 'Quinn', 'Rose', 'Sam', 'Tina']
|
||||
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Martinez', 'Anderson', 'Taylor', 'Thomas', 'Hernandez', 'Moore', 'Martin', 'Jackson', 'Thompson', 'White', 'Lopez', 'Lee']
|
||||
|
||||
function generateData(count: number): Person[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
active: i % 3 !== 0,
|
||||
age: 22 + (i % 45),
|
||||
department: departments[i % departments.length],
|
||||
email: `${firstNames[i % firstNames.length].toLowerCase()}.${lastNames[i % lastNames.length].toLowerCase()}@example.com`,
|
||||
firstName: firstNames[i % firstNames.length],
|
||||
id: i + 1,
|
||||
lastName: lastNames[i % lastNames.length],
|
||||
salary: 40000 + (i * 1234) % 80000,
|
||||
startDate: `202${i % 5}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
|
||||
}))
|
||||
}
|
||||
|
||||
const smallData = generateData(20)
|
||||
const largeData = generateData(10_000)
|
||||
|
||||
// ─── Column Definitions ──────────────────────────────────────────────────────
|
||||
|
||||
const columns: 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: '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 },
|
||||
]
|
||||
|
||||
// ─── Wrapper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function GriddyWrapper(props: GriddyProps<Person>) {
|
||||
return (
|
||||
<Box h="100%" mih="500px" w="100%">
|
||||
<Griddy {...props} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta = {
|
||||
args: {
|
||||
columns,
|
||||
data: smallData,
|
||||
getRowId: (row: Person) => String(row.id),
|
||||
height: 500,
|
||||
},
|
||||
component: GriddyWrapper,
|
||||
parameters: {
|
||||
globalStore: false,
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
title: 'Components/Griddy',
|
||||
} satisfies Meta<typeof GriddyWrapper>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// ─── Stories ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Basic table with 20 rows, sorting enabled by default */
|
||||
export const Basic: Story = {}
|
||||
|
||||
/** 10,000 rows with virtualization */
|
||||
export const LargeDataset: Story = {
|
||||
args: {
|
||||
data: largeData,
|
||||
height: 600,
|
||||
},
|
||||
}
|
||||
|
||||
/** Single row selection mode - click or Space to select */
|
||||
export const SingleSelection: Story = {
|
||||
render: () => {
|
||||
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="500px" w="100%">
|
||||
<Griddy<Person>
|
||||
columns={columns}
|
||||
data={smallData}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={500}
|
||||
onRowSelectionChange={setSelection}
|
||||
rowSelection={selection}
|
||||
selection={{ mode: 'single', selectOnClick: true, showCheckbox: true }}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
Selected: {JSON.stringify(selection)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/** Multi row selection - Shift+Arrow to extend, Ctrl+A to select all, Space to toggle */
|
||||
export const MultiSelection: Story = {
|
||||
render: () => {
|
||||
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="500px" w="100%">
|
||||
<Griddy<Person>
|
||||
columns={columns}
|
||||
data={smallData}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={500}
|
||||
onRowSelectionChange={setSelection}
|
||||
rowSelection={selection}
|
||||
selection={{ mode: 'multi', selectOnClick: true, showCheckbox: true }}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
Selected ({Object.keys(selection).filter(k => selection[k]).length} rows): {JSON.stringify(selection)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/** Multi-select with 10k rows - test keyboard navigation + selection at scale */
|
||||
export const LargeMultiSelection: Story = {
|
||||
render: () => {
|
||||
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="600px" w="100%">
|
||||
<Griddy<Person>
|
||||
columns={columns}
|
||||
data={largeData}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={600}
|
||||
onRowSelectionChange={setSelection}
|
||||
rowSelection={selection}
|
||||
selection={{ mode: 'multi', showCheckbox: true }}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
Selected: {Object.keys(selection).filter(k => selection[k]).length} / {largeData.length} rows
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/** Search enabled - Ctrl+F to open search overlay */
|
||||
export const WithSearch: Story = {
|
||||
args: {
|
||||
search: { enabled: true, highlightMatches: true, placeholder: 'Search people...' },
|
||||
},
|
||||
}
|
||||
|
||||
/** Keyboard navigation guide story */
|
||||
export const KeyboardNavigation: Story = {
|
||||
render: () => {
|
||||
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="600px" w="100%">
|
||||
<Box mb="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontSize: 13 }}>
|
||||
<strong>Keyboard Shortcuts</strong> (click on the grid first to focus it)
|
||||
<table style={{ borderCollapse: 'collapse', marginTop: 8, width: '100%' }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Arrow Up/Down', 'Move focus between rows'],
|
||||
['Page Up/Down', 'Jump by one page'],
|
||||
['Home / End', 'Jump to first / last row'],
|
||||
['Space', 'Toggle selection of focused row'],
|
||||
['Shift + Arrow Up/Down', 'Extend selection (multi-select)'],
|
||||
['Ctrl + A', 'Select all rows'],
|
||||
['Ctrl + F', 'Open search overlay'],
|
||||
['Ctrl + S', 'Toggle selection mode'],
|
||||
['Escape', 'Clear selection / close search'],
|
||||
].map(([key, desc]) => (
|
||||
<tr key={key}>
|
||||
<td style={{ fontFamily: 'monospace', fontWeight: 600, padding: '2px 12px 2px 0', whiteSpace: 'nowrap' }}>{key}</td>
|
||||
<td style={{ padding: '2px 0' }}>{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
<Griddy<Person>
|
||||
columns={columns}
|
||||
data={smallData}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={400}
|
||||
onRowSelectionChange={setSelection}
|
||||
rowSelection={selection}
|
||||
search={{ enabled: true }}
|
||||
selection={{ mode: 'multi', showCheckbox: true }}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
Selected: {Object.keys(selection).filter(k => selection[k]).length} rows
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
247
src/Griddy/core/Griddy.tsx
Normal file
247
src/Griddy/core/Griddy.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ColumnOrderState,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { GriddyProps, GriddyRef } from './types'
|
||||
|
||||
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation'
|
||||
import { SearchOverlay } from '../features/search/SearchOverlay'
|
||||
import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer'
|
||||
import { TableHeader } from '../rendering/TableHeader'
|
||||
import { VirtualBody } from '../rendering/VirtualBody'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { mapColumns } from './columnMapper'
|
||||
import { CSS, DEFAULTS } from './constants'
|
||||
import { GriddyProvider, useGriddyStore } from './GriddyStore'
|
||||
|
||||
// ─── Inner Component (lives inside Provider, has store access) ───────────────
|
||||
|
||||
function _Griddy<T>(props: GriddyProps<T>, ref: Ref<GriddyRef<T>>) {
|
||||
return (
|
||||
<GriddyProvider {...props}>
|
||||
<GriddyInner tableRef={ref} />
|
||||
{props.children}
|
||||
</GriddyProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component with forwardRef ──────────────────────────────────────────
|
||||
|
||||
function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
// Read props from synced store
|
||||
const data = useGriddyStore((s) => s.data)
|
||||
const userColumns = useGriddyStore((s) => s.columns)
|
||||
const getRowId = useGriddyStore((s) => s.getRowId)
|
||||
const selection = useGriddyStore((s) => s.selection)
|
||||
const search = useGriddyStore((s) => s.search)
|
||||
const paginationConfig = useGriddyStore((s) => s.pagination)
|
||||
const controlledSorting = useGriddyStore((s) => s.sorting)
|
||||
const onSortingChange = useGriddyStore((s) => s.onSortingChange)
|
||||
const controlledFilters = useGriddyStore((s) => s.columnFilters)
|
||||
const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange)
|
||||
const controlledRowSelection = useGriddyStore((s) => s.rowSelection)
|
||||
const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange)
|
||||
const onEditCommit = useGriddyStore((s) => s.onEditCommit)
|
||||
const rowHeight = useGriddyStore((s) => s.rowHeight)
|
||||
const overscanProp = useGriddyStore((s) => s.overscan)
|
||||
const height = useGriddyStore((s) => s.height)
|
||||
const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation)
|
||||
const className = useGriddyStore((s) => s.className)
|
||||
const setTable = useGriddyStore((s) => s.setTable)
|
||||
const setVirtualizer = useGriddyStore((s) => s.setVirtualizer)
|
||||
const setScrollRef = useGriddyStore((s) => s.setScrollRef)
|
||||
const setFocusedRow = useGriddyStore((s) => s.setFocusedRow)
|
||||
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn)
|
||||
const setEditing = useGriddyStore((s) => s.setEditing)
|
||||
const setTotalRows = useGriddyStore((s) => s.setTotalRows)
|
||||
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex)
|
||||
|
||||
const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight
|
||||
const effectiveOverscan = overscanProp ?? DEFAULTS.overscan
|
||||
const enableKeyboard = keyboardNavigation !== false
|
||||
|
||||
// ─── Column Mapping ───
|
||||
const columns = useMemo(
|
||||
() => mapColumns(userColumns ?? [], selection) as ColumnDef<T, any>[],
|
||||
[userColumns, selection],
|
||||
)
|
||||
|
||||
// ─── Table State (internal/uncontrolled) ───
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([])
|
||||
const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>([])
|
||||
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
|
||||
const [globalFilter, setGlobalFilter] = useState<string | undefined>(undefined)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([])
|
||||
const [internalPagination, setInternalPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize,
|
||||
})
|
||||
|
||||
// Resolve controlled vs uncontrolled
|
||||
const sorting = controlledSorting ?? internalSorting
|
||||
const setSorting = onSortingChange ?? setInternalSorting
|
||||
const columnFilters = controlledFilters ?? internalFilters
|
||||
const setColumnFilters = onColumnFiltersChange ?? setInternalFilters
|
||||
const rowSelectionState = controlledRowSelection ?? internalRowSelection
|
||||
const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
|
||||
|
||||
// ─── Selection config ───
|
||||
const enableRowSelection = selection ? selection.mode !== 'none' : false
|
||||
const enableMultiRowSelection = selection?.mode === 'multi'
|
||||
|
||||
// ─── TanStack Table Instance ───
|
||||
const table = useReactTable<T>({
|
||||
columns,
|
||||
data: (data ?? []) as T[],
|
||||
enableColumnResizing: true,
|
||||
enableFilters: true,
|
||||
enableMultiRowSelection,
|
||||
enableMultiSort: true,
|
||||
enableRowSelection,
|
||||
enableSorting: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: getRowId as any ?? ((_, index) => String(index)),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters as any,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onPaginationChange: paginationConfig?.enabled ? setInternalPagination : undefined,
|
||||
onRowSelectionChange: setRowSelection as any,
|
||||
onSortingChange: setSorting as any,
|
||||
state: {
|
||||
columnFilters,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
globalFilter,
|
||||
rowSelection: rowSelectionState,
|
||||
sorting,
|
||||
...(paginationConfig?.enabled ? { pagination: internalPagination } : {}),
|
||||
},
|
||||
...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
columnResizeMode: 'onChange',
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
})
|
||||
|
||||
// ─── Scroll Container Ref ───
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// ─── TanStack Virtual ───
|
||||
const virtualizer = useGridVirtualizer({
|
||||
overscan: effectiveOverscan,
|
||||
rowHeight: effectiveRowHeight,
|
||||
scrollRef,
|
||||
table,
|
||||
})
|
||||
|
||||
// ─── Sync table + virtualizer + scrollRef into store ───
|
||||
useEffect(() => { setTable(table) }, [table, setTable])
|
||||
useEffect(() => { setVirtualizer(virtualizer) }, [virtualizer, setVirtualizer])
|
||||
useEffect(() => { setScrollRef(scrollRef.current) }, [setScrollRef])
|
||||
|
||||
// ─── Keyboard Navigation ───
|
||||
// Get the full store state for imperative access in keyboard handler
|
||||
const storeState = useGriddyStore()
|
||||
|
||||
useKeyboardNavigation({
|
||||
editingEnabled: !!onEditCommit,
|
||||
scrollRef,
|
||||
search,
|
||||
selection,
|
||||
storeState,
|
||||
table,
|
||||
virtualizer,
|
||||
})
|
||||
|
||||
// ─── Set initial focus when data loads ───
|
||||
const rowCount = table.getRowModel().rows.length
|
||||
|
||||
useEffect(() => {
|
||||
setTotalRows(rowCount)
|
||||
if (rowCount > 0 && focusedRowIndex === null) {
|
||||
setFocusedRow(0)
|
||||
}
|
||||
}, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow])
|
||||
|
||||
// ─── Imperative Ref ───
|
||||
useImperativeHandle(tableRef, () => ({
|
||||
deselectAll: () => table.resetRowSelection(),
|
||||
focusRow: (index: number) => {
|
||||
setFocusedRow(index)
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' })
|
||||
},
|
||||
getTable: () => table,
|
||||
getUIState: () => ({
|
||||
focusedColumnId: null,
|
||||
focusedRowIndex,
|
||||
isEditing: false,
|
||||
isSearchOpen: false,
|
||||
isSelecting: false,
|
||||
totalRows: rowCount,
|
||||
} as any),
|
||||
getVirtualizer: () => virtualizer,
|
||||
scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }),
|
||||
selectRow: (id: string) => {
|
||||
const row = table.getRowModel().rows.find((r) => r.id === id)
|
||||
row?.toggleSelected(true)
|
||||
},
|
||||
startEditing: (rowId: string, columnId?: string) => {
|
||||
const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId)
|
||||
if (rowIndex >= 0) {
|
||||
setFocusedRow(rowIndex)
|
||||
if (columnId) setFocusedColumn(columnId)
|
||||
setEditing(true)
|
||||
}
|
||||
},
|
||||
}), [table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount])
|
||||
|
||||
// ─── Render ───
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: height ?? '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}
|
||||
|
||||
const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null
|
||||
const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-activedescendant={focusedRowId}
|
||||
aria-label="Data grid"
|
||||
aria-rowcount={(data ?? []).length}
|
||||
className={[styles[CSS.root], className].filter(Boolean).join(' ')}
|
||||
role="grid"
|
||||
>
|
||||
{search?.enabled && <SearchOverlay />}
|
||||
<div
|
||||
className={styles[CSS.container]}
|
||||
ref={scrollRef}
|
||||
style={containerStyle}
|
||||
tabIndex={enableKeyboard ? 0 : undefined}
|
||||
>
|
||||
<TableHeader />
|
||||
<VirtualBody />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Griddy = forwardRef(_Griddy) as <T>(
|
||||
props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>>
|
||||
) => React.ReactElement
|
||||
103
src/Griddy/core/GriddyStore.ts
Normal file
103
src/Griddy/core/GriddyStore.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import type { ColumnFiltersState, RowSelectionState, SortingState } from '@tanstack/react-table'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
|
||||
import { createSyncStore } from '@warkypublic/zustandsyncstore'
|
||||
|
||||
import type { DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingConfig, PaginationConfig, SearchConfig, SelectionConfig } from './types'
|
||||
|
||||
// ─── Store State ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full store state: UI state + synced props + internal references.
|
||||
* Props from GriddyProps are synced automatically via createSyncStore's $sync.
|
||||
* Fields from GriddyProps must be declared here so TypeScript can see them.
|
||||
*/
|
||||
export interface GriddyStoreState extends GriddyUIState {
|
||||
_scrollRef: HTMLDivElement | null
|
||||
// ─── Internal refs (set imperatively) ───
|
||||
_table: null | Table<any>
|
||||
_virtualizer: null | Virtualizer<HTMLDivElement, Element>
|
||||
className?: string
|
||||
columnFilters?: ColumnFiltersState
|
||||
columns?: GriddyColumn<any>[]
|
||||
data?: any[]
|
||||
dataAdapter?: DataAdapter<any>
|
||||
getRowId?: (row: any, index: number) => string
|
||||
grouping?: GroupingConfig
|
||||
height?: number | string
|
||||
keyboardNavigation?: boolean
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
|
||||
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void
|
||||
onSortingChange?: (sorting: SortingState) => void
|
||||
overscan?: number
|
||||
pagination?: PaginationConfig
|
||||
persistenceKey?: string
|
||||
rowHeight?: number
|
||||
rowSelection?: RowSelectionState
|
||||
search?: SearchConfig
|
||||
|
||||
selection?: SelectionConfig
|
||||
setScrollRef: (el: HTMLDivElement | null) => void
|
||||
// ─── Internal ref setters ───
|
||||
setTable: (table: Table<any>) => void
|
||||
|
||||
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void
|
||||
sorting?: SortingState
|
||||
// ─── Synced from GriddyProps (written by $sync) ───
|
||||
uniqueId?: string
|
||||
}
|
||||
|
||||
// ─── Create Store ────────────────────────────────────────────────────────────
|
||||
|
||||
export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
|
||||
GriddyStoreState,
|
||||
GriddyProps<any>
|
||||
>(
|
||||
(set, get) => ({
|
||||
_scrollRef: null,
|
||||
// ─── Internal Refs ───
|
||||
_table: null,
|
||||
|
||||
_virtualizer: null,
|
||||
focusedColumnId: null,
|
||||
// ─── Focus State ───
|
||||
focusedRowIndex: null,
|
||||
|
||||
// ─── Mode State ───
|
||||
isEditing: false,
|
||||
|
||||
isSearchOpen: false,
|
||||
isSelecting: false,
|
||||
moveFocus: (direction, amount) => {
|
||||
const { focusedRowIndex, totalRows } = get()
|
||||
const current = focusedRowIndex ?? 0
|
||||
const delta = direction === 'down' ? amount : -amount
|
||||
const next = Math.max(0, Math.min(current + delta, totalRows - 1))
|
||||
set({ focusedRowIndex: next })
|
||||
},
|
||||
|
||||
moveFocusToEnd: () => {
|
||||
const { totalRows } = get()
|
||||
set({ focusedRowIndex: Math.max(0, totalRows - 1) })
|
||||
},
|
||||
moveFocusToStart: () => set({ focusedRowIndex: 0 }),
|
||||
setEditing: (editing) => set({ isEditing: editing }),
|
||||
setFocusedColumn: (id) => set({ focusedColumnId: id }),
|
||||
// ─── Actions ───
|
||||
setFocusedRow: (index) => set({ focusedRowIndex: index }),
|
||||
setScrollRef: (el) => set({ _scrollRef: el }),
|
||||
|
||||
setSearchOpen: (open) => set({ isSearchOpen: open }),
|
||||
|
||||
setSelecting: (selecting) => set({ isSelecting: selecting }),
|
||||
// ─── Internal Ref Setters ───
|
||||
setTable: (table) => set({ _table: table }),
|
||||
|
||||
setTotalRows: (count) => set({ totalRows: count }),
|
||||
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
|
||||
// ─── Row Count ───
|
||||
totalRows: 0,
|
||||
}),
|
||||
)
|
||||
66
src/Griddy/core/columnMapper.ts
Normal file
66
src/Griddy/core/columnMapper.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import type { GriddyColumn, SelectionConfig } from './types'
|
||||
|
||||
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants'
|
||||
|
||||
/**
|
||||
* Retrieves the original GriddyColumn from a TanStack column's meta.
|
||||
*/
|
||||
export function getGriddyColumn<T>(column: { columnDef: ColumnDef<T> }): GriddyColumn<T> | undefined {
|
||||
return (column.columnDef.meta as { griddy?: GriddyColumn<T> })?.griddy
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Griddy's user-facing GriddyColumn<T> definitions to TanStack Table ColumnDef<T>[].
|
||||
* Optionally prepends a selection checkbox column.
|
||||
*/
|
||||
export function mapColumns<T>(
|
||||
columns: GriddyColumn<T>[],
|
||||
selection?: SelectionConfig,
|
||||
): ColumnDef<T>[] {
|
||||
const mapped: ColumnDef<T>[] = columns.map((col) => {
|
||||
const isStringAccessor = typeof col.accessor !== 'function'
|
||||
|
||||
const def: ColumnDef<T> = {
|
||||
id: col.id,
|
||||
// Use accessorKey for string keys (enables TanStack auto-detection of sort/filter),
|
||||
// accessorFn for function accessors
|
||||
...(isStringAccessor
|
||||
? { accessorKey: col.accessor as string }
|
||||
: { accessorFn: col.accessor as (row: T) => unknown }),
|
||||
enableColumnFilter: col.filterable ?? false,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
enableSorting: col.sortable ?? true,
|
||||
header: () => col.header,
|
||||
maxSize: col.maxWidth ?? DEFAULTS.maxColumnWidth,
|
||||
meta: { griddy: col },
|
||||
minSize: col.minWidth ?? DEFAULTS.minColumnWidth,
|
||||
size: col.width,
|
||||
// For function accessors, TanStack can't auto-detect the sort type, so default to 'auto'
|
||||
sortingFn: col.sortFn ?? (isStringAccessor ? undefined : 'auto') as any,
|
||||
}
|
||||
if (col.filterFn) def.filterFn = col.filterFn
|
||||
return def
|
||||
})
|
||||
|
||||
// Prepend checkbox column if selection is enabled
|
||||
if (selection && selection.mode !== 'none' && selection.showCheckbox !== false) {
|
||||
const checkboxCol: ColumnDef<T> = {
|
||||
cell: 'select-row', // Rendered by TableCell with actual checkbox
|
||||
enableColumnFilter: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
enableSorting: false,
|
||||
header: selection.mode === 'multi'
|
||||
? 'select-all' // Rendered by TableHeader with actual checkbox
|
||||
: '',
|
||||
id: SELECTION_COLUMN_ID,
|
||||
size: SELECTION_COLUMN_SIZE,
|
||||
}
|
||||
mapped.unshift(checkboxCol)
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
43
src/Griddy/core/constants.ts
Normal file
43
src/Griddy/core/constants.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// ─── CSS Class Names ─────────────────────────────────────────────────────────
|
||||
|
||||
export const CSS = {
|
||||
cell: 'griddy-cell',
|
||||
cellEditing: 'griddy-cell--editing',
|
||||
checkbox: 'griddy-checkbox',
|
||||
container: 'griddy-container',
|
||||
headerCell: 'griddy-header-cell',
|
||||
headerCellSortable: 'griddy-header-cell--sortable',
|
||||
headerCellSorted: 'griddy-header-cell--sorted',
|
||||
headerRow: 'griddy-header-row',
|
||||
resizeHandle: 'griddy-resize-handle',
|
||||
root: 'griddy',
|
||||
row: 'griddy-row',
|
||||
rowEven: 'griddy-row--even',
|
||||
rowFocused: 'griddy-row--focused',
|
||||
rowOdd: 'griddy-row--odd',
|
||||
rowSelected: 'griddy-row--selected',
|
||||
searchInput: 'griddy-search-input',
|
||||
searchOverlay: 'griddy-search-overlay',
|
||||
sortIndicator: 'griddy-sort-indicator',
|
||||
table: 'griddy-table',
|
||||
tbody: 'griddy-tbody',
|
||||
thead: 'griddy-thead',
|
||||
} as const
|
||||
|
||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULTS = {
|
||||
headerHeight: 36,
|
||||
maxColumnWidth: 800,
|
||||
minColumnWidth: 50,
|
||||
overscan: 10,
|
||||
pageSize: 50,
|
||||
pageSizeOptions: [25, 50, 100] as number[],
|
||||
rowHeight: 36,
|
||||
searchDebounceMs: 300,
|
||||
} as const
|
||||
|
||||
// ─── Selection Column ────────────────────────────────────────────────────────
|
||||
|
||||
export const SELECTION_COLUMN_ID = '_selection'
|
||||
export const SELECTION_COLUMN_SIZE = 40
|
||||
233
src/Griddy/core/types.ts
Normal file
233
src/Griddy/core/types.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, FilterFn, GroupingState, PaginationState, RowSelectionState, SortingFn, SortingState, Table, VisibilityState } from '@tanstack/react-table'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// ─── Column Definition ───────────────────────────────────────────────────────
|
||||
|
||||
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode
|
||||
|
||||
// ─── Cell Rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface DataAdapter<T> {
|
||||
delete?: (row: T) => Promise<void>
|
||||
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>
|
||||
save?: (row: T) => Promise<void>
|
||||
}
|
||||
|
||||
export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode
|
||||
|
||||
// ─── Editors ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EditorProps<T> {
|
||||
column: GriddyColumn<T>
|
||||
onCancel: () => void
|
||||
onCommit: (newValue: unknown) => void
|
||||
onMoveNext: () => void
|
||||
onMovePrev: () => void
|
||||
row: T
|
||||
rowIndex: number
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface FetchConfig {
|
||||
cursor?: string
|
||||
filters?: ColumnFiltersState
|
||||
globalFilter?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sorting?: SortingState
|
||||
}
|
||||
|
||||
// ─── Selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GriddyColumn<T> {
|
||||
accessor: ((row: T) => unknown) | keyof T
|
||||
editable?: ((row: T) => boolean) | boolean
|
||||
editor?: EditorComponent<T>
|
||||
filterable?: boolean
|
||||
filterFn?: FilterFn<T>
|
||||
header: ReactNode | string
|
||||
headerGroup?: string
|
||||
hidden?: boolean
|
||||
id: string
|
||||
maxWidth?: number
|
||||
minWidth?: number
|
||||
pinned?: 'left' | 'right'
|
||||
renderer?: CellRenderer<T>
|
||||
searchable?: boolean
|
||||
sortable?: boolean
|
||||
sortFn?: SortingFn<T>
|
||||
width?: number
|
||||
}
|
||||
|
||||
// ─── Search ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GriddyDataSource<T> {
|
||||
data: T[]
|
||||
error?: Error
|
||||
isLoading?: boolean
|
||||
pageInfo?: { cursor?: string; hasNextPage: boolean; }
|
||||
total?: number
|
||||
}
|
||||
|
||||
// ─── Pagination ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GriddyProps<T> {
|
||||
// ─── Children (adapters, etc.) ───
|
||||
children?: ReactNode
|
||||
// ─── Styling ───
|
||||
className?: string
|
||||
// ─── Filtering ───
|
||||
/** Controlled column filters state */
|
||||
columnFilters?: ColumnFiltersState
|
||||
/** Column definitions */
|
||||
columns: GriddyColumn<T>[]
|
||||
|
||||
/** Data array */
|
||||
data: T[]
|
||||
|
||||
// ─── Data Adapter ───
|
||||
dataAdapter?: DataAdapter<T>
|
||||
/** Stable row identity function */
|
||||
getRowId?: (row: T, index: number) => string
|
||||
// ─── Grouping ───
|
||||
grouping?: GroupingConfig
|
||||
|
||||
/** Container height */
|
||||
height?: number | string
|
||||
// ─── Keyboard ───
|
||||
/** Enable keyboard navigation. Default: true */
|
||||
keyboardNavigation?: boolean
|
||||
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
|
||||
// ─── Editing ───
|
||||
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
|
||||
|
||||
/** Selection change callback */
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void
|
||||
|
||||
onSortingChange?: (sorting: SortingState) => void
|
||||
|
||||
/** Overscan row count. Default: 10 */
|
||||
overscan?: number
|
||||
|
||||
// ─── Pagination ───
|
||||
pagination?: PaginationConfig
|
||||
|
||||
// ─── Persistence ───
|
||||
/** localStorage key prefix for persisting column layout */
|
||||
persistenceKey?: string
|
||||
// ─── Virtualization ───
|
||||
/** Row height in pixels. Default: 36 */
|
||||
rowHeight?: number
|
||||
/** Controlled row selection state */
|
||||
rowSelection?: RowSelectionState
|
||||
|
||||
// ─── Search ───
|
||||
search?: SearchConfig
|
||||
|
||||
// ─── Selection ───
|
||||
/** Selection configuration */
|
||||
selection?: SelectionConfig
|
||||
|
||||
// ─── Sorting ───
|
||||
/** Controlled sorting state */
|
||||
sorting?: SortingState
|
||||
|
||||
/** Unique identifier for persistence */
|
||||
uniqueId?: string
|
||||
}
|
||||
|
||||
// ─── Data Adapter ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GriddyRef<T = unknown> {
|
||||
deselectAll: () => void
|
||||
focusRow: (index: number) => void
|
||||
getTable: () => Table<T>
|
||||
getUIState: () => GriddyUIState
|
||||
getVirtualizer: () => Virtualizer<HTMLDivElement, Element>
|
||||
scrollToRow: (index: number) => void
|
||||
selectRow: (id: string) => void
|
||||
startEditing: (rowId: string, columnId?: string) => void
|
||||
}
|
||||
|
||||
export interface GriddyUIState {
|
||||
focusedColumnId: null | string
|
||||
// Focus
|
||||
focusedRowIndex: null | number
|
||||
|
||||
// Modes
|
||||
isEditing: boolean
|
||||
isSearchOpen: boolean
|
||||
isSelecting: boolean
|
||||
|
||||
moveFocus: (direction: 'down' | 'up', amount: number) => void
|
||||
|
||||
moveFocusToEnd: () => void
|
||||
moveFocusToStart: () => void
|
||||
setEditing: (editing: boolean) => void
|
||||
setFocusedColumn: (id: null | string) => void
|
||||
// Actions
|
||||
setFocusedRow: (index: null | number) => void
|
||||
setSearchOpen: (open: boolean) => void
|
||||
setSelecting: (selecting: boolean) => void
|
||||
setTotalRows: (count: number) => void
|
||||
// Row count (synced from table)
|
||||
totalRows: number
|
||||
}
|
||||
|
||||
export interface GroupingConfig {
|
||||
columns?: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ─── Grouping ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaginationConfig {
|
||||
enabled: boolean
|
||||
onPageChange?: (page: number) => void
|
||||
onPageSizeChange?: (pageSize: number) => void
|
||||
pageSize: number
|
||||
pageSizeOptions?: number[]
|
||||
type: 'cursor' | 'offset'
|
||||
}
|
||||
|
||||
// ─── Main Props ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RendererProps<T> {
|
||||
column: GriddyColumn<T>
|
||||
columnIndex: number
|
||||
isEditing?: boolean
|
||||
row: T
|
||||
rowIndex: number
|
||||
searchQuery?: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
// ─── UI State (Zustand Store) ────────────────────────────────────────────────
|
||||
|
||||
export interface SearchConfig {
|
||||
caseSensitive?: boolean
|
||||
debounceMs?: number
|
||||
enabled: boolean
|
||||
fuzzy?: boolean
|
||||
highlightMatches?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ─── Ref API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SelectionConfig {
|
||||
/** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */
|
||||
mode: 'multi' | 'none' | 'single'
|
||||
/** Maintain selection across pagination/sorting. Default: true */
|
||||
preserveSelection?: boolean
|
||||
/** Allow clicking row body to toggle selection. Default: true */
|
||||
selectOnClick?: boolean
|
||||
/** Show checkbox column (auto-added as first column). Default: true when mode !== 'none' */
|
||||
showCheckbox?: boolean
|
||||
}
|
||||
|
||||
// ─── Re-exports for convenience ──────────────────────────────────────────────
|
||||
|
||||
export type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, GroupingState, PaginationState, RowSelectionState, SortingState, Table, VisibilityState }
|
||||
221
src/Griddy/features/keyboard/useKeyboardNavigation.ts
Normal file
221
src/Griddy/features/keyboard/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
|
||||
import { type RefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { GriddyUIState, SearchConfig, SelectionConfig } from '../../core/types'
|
||||
|
||||
interface UseKeyboardNavigationOptions<TData = unknown> {
|
||||
editingEnabled: boolean
|
||||
scrollRef: RefObject<HTMLDivElement | null>
|
||||
search?: SearchConfig
|
||||
selection?: SelectionConfig
|
||||
storeState: GriddyUIState
|
||||
table: Table<TData>
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
}
|
||||
|
||||
export function useKeyboardNavigation<TData = unknown>({
|
||||
editingEnabled,
|
||||
scrollRef,
|
||||
search,
|
||||
selection,
|
||||
storeState,
|
||||
table,
|
||||
virtualizer,
|
||||
}: UseKeyboardNavigationOptions<TData>) {
|
||||
// Keep a ref to the latest store state so the keydown handler always sees fresh state
|
||||
const stateRef = useRef(storeState)
|
||||
stateRef.current = storeState
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const state = stateRef.current
|
||||
const { focusedRowIndex, isEditing, isSearchOpen } = state
|
||||
const rowCount = table.getRowModel().rows.length
|
||||
const visibleCount = virtualizer.getVirtualItems().length
|
||||
const selectionMode = selection?.mode ?? 'none'
|
||||
const multiSelect = selection?.mode === 'multi'
|
||||
|
||||
// ─── Search mode: only Escape exits ───
|
||||
if (isSearchOpen) {
|
||||
if (e.key === 'Escape') {
|
||||
state.setSearchOpen(false)
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Edit mode: only Escape exits at grid level ───
|
||||
if (isEditing) {
|
||||
if (e.key === 'Escape') {
|
||||
state.setEditing(false)
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Normal mode ───
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
const shift = e.shiftKey
|
||||
|
||||
// Handle shift+arrow before plain arrow
|
||||
if (shift && !ctrl) {
|
||||
if (e.key === 'ArrowDown' && multiSelect && focusedRowIndex !== null) {
|
||||
e.preventDefault()
|
||||
const nextIdx = Math.min(focusedRowIndex + 1, rowCount - 1)
|
||||
const row = table.getRowModel().rows[nextIdx]
|
||||
row?.toggleSelected(true)
|
||||
state.moveFocus('down', 1)
|
||||
virtualizer.scrollToIndex(Math.min(focusedRowIndex + 1, rowCount - 1), { align: 'auto' })
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp' && multiSelect && focusedRowIndex !== null) {
|
||||
e.preventDefault()
|
||||
const prevIdx = Math.max(focusedRowIndex - 1, 0)
|
||||
const row = table.getRowModel().rows[prevIdx]
|
||||
row?.toggleSelected(true)
|
||||
state.moveFocus('up', 1)
|
||||
virtualizer.scrollToIndex(Math.max(focusedRowIndex - 1, 0), { align: 'auto' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let didNavigate: boolean
|
||||
|
||||
switch (e.key) {
|
||||
case ' ': {
|
||||
if (selectionMode !== 'none' && focusedRowIndex !== null) {
|
||||
e.preventDefault()
|
||||
const row = table.getRowModel().rows[focusedRowIndex]
|
||||
if (row) {
|
||||
if (selectionMode === 'single') {
|
||||
table.resetRowSelection()
|
||||
row.toggleSelected(true)
|
||||
} else {
|
||||
row.toggleSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'a': {
|
||||
if (ctrl && multiSelect) {
|
||||
e.preventDefault()
|
||||
table.toggleAllRowsSelected()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
state.moveFocus('down', 1)
|
||||
didNavigate = true
|
||||
break
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
state.moveFocus('up', 1)
|
||||
didNavigate = true
|
||||
break
|
||||
}
|
||||
|
||||
case 'e': {
|
||||
if (ctrl && editingEnabled && focusedRowIndex !== null) {
|
||||
e.preventDefault()
|
||||
state.setEditing(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'End': {
|
||||
e.preventDefault()
|
||||
state.moveFocusToEnd()
|
||||
didNavigate = true
|
||||
break
|
||||
}
|
||||
|
||||
case 'Enter': {
|
||||
if (editingEnabled && focusedRowIndex !== null && !ctrl) {
|
||||
e.preventDefault()
|
||||
state.setEditing(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'Escape': {
|
||||
if (state.isSelecting) {
|
||||
state.setSelecting(false)
|
||||
e.preventDefault()
|
||||
} else if (selectionMode !== 'none') {
|
||||
table.resetRowSelection()
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'f': {
|
||||
if (ctrl && search?.enabled) {
|
||||
e.preventDefault()
|
||||
state.setSearchOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'Home': {
|
||||
e.preventDefault()
|
||||
state.moveFocusToStart()
|
||||
didNavigate = true
|
||||
break
|
||||
}
|
||||
|
||||
case 'PageDown': {
|
||||
e.preventDefault()
|
||||
state.moveFocus('down', visibleCount)
|
||||
didNavigate = true
|
||||
break
|
||||
}
|
||||
|
||||
case 'PageUp': {
|
||||
e.preventDefault()
|
||||
state.moveFocus('up', visibleCount)
|
||||
didNavigate = true
|
||||
break
|
||||
}
|
||||
|
||||
case 's': {
|
||||
if (ctrl && selectionMode !== 'none') {
|
||||
e.preventDefault()
|
||||
state.setSelecting(!state.isSelecting)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-scroll after navigation keys
|
||||
if (didNavigate && focusedRowIndex !== null) {
|
||||
// Estimate the new position based on the action
|
||||
const newIndex = Math.max(0, Math.min(
|
||||
e.key === 'Home' ? 0 :
|
||||
e.key === 'End' ? rowCount - 1 :
|
||||
e.key === 'PageDown' ? focusedRowIndex + visibleCount :
|
||||
e.key === 'PageUp' ? focusedRowIndex - visibleCount :
|
||||
e.key === 'ArrowDown' ? focusedRowIndex + 1 :
|
||||
focusedRowIndex - 1,
|
||||
rowCount - 1,
|
||||
))
|
||||
virtualizer.scrollToIndex(newIndex, { align: 'auto' })
|
||||
}
|
||||
}, [table, virtualizer, selection, search, editingEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.addEventListener('keydown', handleKeyDown)
|
||||
return () => el.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown, scrollRef])
|
||||
}
|
||||
62
src/Griddy/features/search/SearchOverlay.tsx
Normal file
62
src/Griddy/features/search/SearchOverlay.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { TextInput } from '@mantine/core'
|
||||
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'
|
||||
|
||||
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 [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<null | ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const debounceMs = search?.debounceMs ?? DEFAULTS.searchDebounceMs
|
||||
const placeholder = search?.placeholder ?? 'Search...'
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchOpen) {
|
||||
inputRef.current?.focus()
|
||||
} else {
|
||||
setQuery('')
|
||||
table?.setGlobalFilter(undefined)
|
||||
}
|
||||
}, [isSearchOpen, table])
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setQuery(value)
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
table?.setGlobalFilter(value || undefined)
|
||||
}, debounceMs)
|
||||
}, [table, debounceMs])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSearchOpen(false)
|
||||
}
|
||||
}, [setSearchOpen])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
22
src/Griddy/index.ts
Normal file
22
src/Griddy/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { getGriddyColumn, mapColumns } from './core/columnMapper'
|
||||
export { CSS, DEFAULTS, SELECTION_COLUMN_ID } from './core/constants'
|
||||
export { Griddy } from './core/Griddy'
|
||||
export { GriddyProvider, useGriddyStore } from './core/GriddyStore'
|
||||
export type { GriddyStoreState } from './core/GriddyStore'
|
||||
export type {
|
||||
CellRenderer,
|
||||
DataAdapter,
|
||||
EditorComponent,
|
||||
EditorProps,
|
||||
FetchConfig,
|
||||
GriddyColumn,
|
||||
GriddyDataSource,
|
||||
GriddyProps,
|
||||
GriddyRef,
|
||||
GriddyUIState,
|
||||
GroupingConfig,
|
||||
PaginationConfig,
|
||||
RendererProps,
|
||||
SearchConfig,
|
||||
SelectionConfig,
|
||||
} from './core/types'
|
||||
@@ -850,28 +850,32 @@ Rendering uses `table.getLeftHeaderGroups()`, `table.getCenterHeaderGroups()`, `
|
||||
|
||||
## Architectural Patterns from Gridler to Adopt
|
||||
|
||||
### 1. Zustand Store Pattern
|
||||
For UI state not managed by TanStack Table:
|
||||
### 1. createSyncStore Pattern (from @warkypublic/zustandsyncstore)
|
||||
Uses `createSyncStore` which provides a Provider that auto-syncs parent props into the Zustand store, plus a context-scoped `useStore` hook with selector support. `GriddyStoreState` includes both UI state AND synced prop fields (so TypeScript sees them):
|
||||
```typescript
|
||||
const { Provider, useGriddyStore } = createSyncStore<GriddyUIState, GriddyStoreProps>(
|
||||
const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
|
||||
GriddyStoreState, // UI state + prop fields + internal refs
|
||||
GriddyProps<any> // Props synced from parent
|
||||
>(
|
||||
(set, get) => ({
|
||||
// UI state
|
||||
focusedRowIndex: null,
|
||||
isEditing: false,
|
||||
isSearchOpen: false,
|
||||
isSelecting: false,
|
||||
// Internal refs
|
||||
_table: null,
|
||||
_virtualizer: null,
|
||||
// Actions
|
||||
setFocusedRow: (index) => set({ focusedRowIndex: index }),
|
||||
setEditing: (editing) => set({ isEditing: editing }),
|
||||
setSearchOpen: (open) => set({ isSearchOpen: open }),
|
||||
setSelecting: (selecting) => set({ isSelecting: selecting }),
|
||||
moveFocus: (direction, amount) => set(state => {
|
||||
const current = state.focusedRowIndex ?? 0
|
||||
const delta = direction === 'down' ? amount : -amount
|
||||
return { focusedRowIndex: Math.max(0, Math.min(current + delta, get().totalRows - 1)) }
|
||||
}),
|
||||
moveFocusToStart: () => set({ focusedRowIndex: 0 }),
|
||||
moveFocusToEnd: () => set(state => ({ focusedRowIndex: get().totalRows - 1 })),
|
||||
moveFocus: (direction, amount) => { ... },
|
||||
setTable: (table) => set({ _table: table }),
|
||||
...
|
||||
})
|
||||
)
|
||||
|
||||
// Usage: <GriddyProvider {...props}><GriddyInner /></GriddyProvider>
|
||||
// All props (data, columns, selection, etc.) are available via useGriddyStore((s) => s.data)
|
||||
```
|
||||
|
||||
### 2. Data Adapter Pattern
|
||||
|
||||
48
src/Griddy/rendering/TableCell.tsx
Normal file
48
src/Griddy/rendering/TableCell.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Checkbox } from '@mantine/core'
|
||||
import { type Cell, flexRender } from '@tanstack/react-table'
|
||||
|
||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
|
||||
interface TableCellProps<T> {
|
||||
cell: Cell<T, unknown>
|
||||
}
|
||||
|
||||
export function TableCell<T>({ cell }: TableCellProps<T>) {
|
||||
const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID
|
||||
|
||||
if (isSelectionCol) {
|
||||
return <RowCheckbox cell={cell} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles[CSS.cell]}
|
||||
role="gridcell"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowCheckbox<T>({ cell }: TableCellProps<T>) {
|
||||
const row = cell.row
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles[CSS.cell]}
|
||||
role="gridcell"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
<Checkbox
|
||||
aria-label={`Select row ${row.index + 1}`}
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/Griddy/rendering/TableHeader.tsx
Normal file
80
src/Griddy/rendering/TableHeader.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Checkbox } from '@mantine/core'
|
||||
import { flexRender } from '@tanstack/react-table'
|
||||
|
||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
|
||||
export function TableHeader() {
|
||||
const table = useGriddyStore((s) => s._table)
|
||||
if (!table) return null
|
||||
|
||||
const headerGroups = table.getHeaderGroups()
|
||||
|
||||
return (
|
||||
<div className={styles[CSS.thead]} role="rowgroup">
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<div className={styles[CSS.headerRow]} key={headerGroup.id} role="row">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isSortable = header.column.getCanSort()
|
||||
const sortDir = header.column.getIsSorted()
|
||||
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'}
|
||||
className={[
|
||||
styles[CSS.headerCell],
|
||||
isSortable ? styles[CSS.headerCellSortable] : '',
|
||||
sortDir ? styles[CSS.headerCellSorted] : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
key={header.id}
|
||||
onClick={isSortable ? header.column.getToggleSortingHandler() : undefined}
|
||||
role="columnheader"
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{isSelectionCol ? (
|
||||
<SelectAllCheckbox />
|
||||
) : header.isPlaceholder ? null : (
|
||||
<>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{sortDir && (
|
||||
<span className={styles[CSS.sortIndicator]}>
|
||||
{sortDir === 'asc' ? ' \u2191' : ' \u2193'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
className={styles[CSS.resizeHandle]}
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectAllCheckbox() {
|
||||
const table = useGriddyStore((s) => s._table)
|
||||
const selection = useGriddyStore((s) => s.selection)
|
||||
|
||||
if (!table || !selection || selection.mode !== 'multi') return null
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label="Select all rows"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
68
src/Griddy/rendering/TableRow.tsx
Normal file
68
src/Griddy/rendering/TableRow.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Row } from '@tanstack/react-table'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { CSS } from '../core/constants'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { TableCell } from './TableCell'
|
||||
|
||||
interface TableRowProps<T> {
|
||||
row: Row<T>
|
||||
size: number
|
||||
start: number
|
||||
}
|
||||
|
||||
export function TableRow<T>({ row, size, start }: TableRowProps<T>) {
|
||||
const selection = useGriddyStore((s) => s.selection)
|
||||
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex)
|
||||
const setFocusedRow = useGriddyStore((s) => s.setFocusedRow)
|
||||
|
||||
const isFocused = focusedRowIndex === row.index
|
||||
const isSelected = row.getIsSelected()
|
||||
const isEven = row.index % 2 === 0
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setFocusedRow(row.index)
|
||||
|
||||
if (selection && selection.mode !== 'none' && selection.selectOnClick !== false) {
|
||||
if (selection.mode === 'single') {
|
||||
row.toggleSelected(true)
|
||||
} else {
|
||||
row.toggleSelected()
|
||||
}
|
||||
}
|
||||
}, [row, selection, setFocusedRow])
|
||||
|
||||
const classNames = [
|
||||
styles[CSS.row],
|
||||
isFocused ? styles[CSS.rowFocused] : '',
|
||||
isSelected ? styles[CSS.rowSelected] : '',
|
||||
isEven ? styles[CSS.rowEven] : '',
|
||||
!isEven ? styles[CSS.rowOdd] : '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-rowindex={row.index + 1}
|
||||
aria-selected={isSelected}
|
||||
className={classNames}
|
||||
id={`griddy-row-${row.id}`}
|
||||
onClick={handleClick}
|
||||
role="row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: size,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${start}px)`,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell cell={cell} key={cell.id} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/Griddy/rendering/VirtualBody.tsx
Normal file
51
src/Griddy/rendering/VirtualBody.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { CSS } from '../core/constants'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { TableRow } from './TableRow'
|
||||
|
||||
export function VirtualBody() {
|
||||
const table = useGriddyStore((s) => s._table)
|
||||
const virtualizer = useGriddyStore((s) => s._virtualizer)
|
||||
const setTotalRows = useGriddyStore((s) => s.setTotalRows)
|
||||
|
||||
const rows = table?.getRowModel().rows
|
||||
const virtualRows = virtualizer?.getVirtualItems()
|
||||
const totalSize = virtualizer?.getTotalSize() ?? 0
|
||||
|
||||
// Sync row count to store for keyboard navigation bounds
|
||||
useEffect(() => {
|
||||
if (rows) {
|
||||
setTotalRows(rows.length)
|
||||
}
|
||||
}, [rows?.length, setTotalRows])
|
||||
|
||||
if (!table || !virtualizer || !rows || !virtualRows) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles[CSS.tbody]}
|
||||
role="rowgroup"
|
||||
style={{
|
||||
height: totalSize,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
if (!row) return null
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
size={virtualRow.size}
|
||||
start={virtualRow.start}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/Griddy/rendering/hooks/useGridVirtualizer.ts
Normal file
29
src/Griddy/rendering/hooks/useGridVirtualizer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
|
||||
import { DEFAULTS } from '../../core/constants'
|
||||
|
||||
interface UseGridVirtualizerOptions {
|
||||
overscan?: number
|
||||
rowHeight?: number
|
||||
scrollRef: RefObject<HTMLDivElement | null>
|
||||
table: Table<any>
|
||||
}
|
||||
|
||||
export function useGridVirtualizer({
|
||||
overscan = DEFAULTS.overscan,
|
||||
rowHeight = DEFAULTS.rowHeight,
|
||||
scrollRef,
|
||||
table,
|
||||
}: UseGridVirtualizerOptions): Virtualizer<HTMLDivElement, Element> {
|
||||
const rowCount = table.getRowModel().rows.length
|
||||
|
||||
return useVirtualizer({
|
||||
count: rowCount,
|
||||
estimateSize: () => rowHeight,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan,
|
||||
})
|
||||
}
|
||||
212
src/Griddy/styles/griddy.module.css
Normal file
212
src/Griddy/styles/griddy.module.css
Normal file
@@ -0,0 +1,212 @@
|
||||
/* ─── Root ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy {
|
||||
--griddy-font-family: inherit;
|
||||
--griddy-font-size: 14px;
|
||||
--griddy-border-color: #e0e0e0;
|
||||
--griddy-header-bg: #f8f9fa;
|
||||
--griddy-header-color: #212529;
|
||||
--griddy-row-bg: #ffffff;
|
||||
--griddy-row-hover-bg: #f1f3f5;
|
||||
--griddy-row-even-bg: #f8f9fa;
|
||||
--griddy-focus-color: #228be6;
|
||||
--griddy-selection-bg: rgba(34, 139, 230, 0.1);
|
||||
--griddy-cell-padding: 0 8px;
|
||||
--griddy-search-bg: #ffffff;
|
||||
--griddy-search-border: #dee2e6;
|
||||
|
||||
font-family: var(--griddy-font-family);
|
||||
font-size: var(--griddy-font-size);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 1px solid var(--griddy-border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Container (scroll area) ──────────────────────────────────────────── */
|
||||
|
||||
.griddy-container {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.griddy-container:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--griddy-focus-color);
|
||||
}
|
||||
|
||||
/* ─── Header ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--griddy-header-bg);
|
||||
border-bottom: 2px solid var(--griddy-border-color);
|
||||
}
|
||||
|
||||
.griddy-header-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.griddy-header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--griddy-cell-padding);
|
||||
height: 36px;
|
||||
font-weight: 600;
|
||||
color: var(--griddy-header-color);
|
||||
border-right: 1px solid var(--griddy-border-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.griddy-header-cell:last-child {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.griddy-header-cell--sortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.griddy-header-cell--sortable:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.griddy-header-cell--sorted {
|
||||
color: var(--griddy-focus-color);
|
||||
}
|
||||
|
||||
/* ─── Sort Indicator ───────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-sort-indicator {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── Resize Handle ────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.griddy-resize-handle:hover {
|
||||
background: var(--griddy-focus-color);
|
||||
}
|
||||
|
||||
/* ─── Body ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-tbody {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ─── Row ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--griddy-border-color);
|
||||
background: var(--griddy-row-bg);
|
||||
cursor: default;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.griddy-row:hover {
|
||||
background: var(--griddy-row-hover-bg);
|
||||
}
|
||||
|
||||
.griddy-row--even {
|
||||
background: var(--griddy-row-even-bg);
|
||||
}
|
||||
|
||||
.griddy-row--even:hover {
|
||||
background: var(--griddy-row-hover-bg);
|
||||
}
|
||||
|
||||
.griddy-row--focused {
|
||||
outline: 2px solid var(--griddy-focus-color);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.griddy-row--selected {
|
||||
background-color: var(--griddy-selection-bg);
|
||||
}
|
||||
|
||||
.griddy-row--selected:hover {
|
||||
background-color: rgba(34, 139, 230, 0.15);
|
||||
}
|
||||
|
||||
.griddy-row--focused.griddy-row--selected {
|
||||
outline: 2px solid var(--griddy-focus-color);
|
||||
background-color: var(--griddy-selection-bg);
|
||||
}
|
||||
|
||||
/* ─── Cell ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--griddy-cell-padding);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-right: 1px solid var(--griddy-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.griddy-cell:last-child {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.griddy-cell--editing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ─── Checkbox ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ─── Search Overlay ───────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-search-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
padding: 8px;
|
||||
background: var(--griddy-search-bg);
|
||||
border: 1px solid var(--griddy-search-border);
|
||||
border-radius: 0 0 0 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.griddy-search-input {
|
||||
font-size: var(--griddy-font-size);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--griddy-search-border);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.griddy-search-input:focus {
|
||||
border-color: var(--griddy-focus-color);
|
||||
box-shadow: 0 0 0 2px rgba(34, 139, 230, 0.2);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from './ErrorBoundary';
|
||||
export * from './Former';
|
||||
export * from './FormerControllers';
|
||||
export * from './GlobalStateStore';
|
||||
export * from './Griddy';
|
||||
export * from './Gridler';
|
||||
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user