A Griddy AI prototype

This commit is contained in:
2026-02-12 22:02:39 +02:00
parent e45a4d70f6
commit 7ecafc8461
19 changed files with 1835 additions and 13 deletions

View File

@@ -22,6 +22,7 @@ export const PreviewDecorator: Decorator = (Story, context) => {
return (
<MantineProvider>
<ModalsProvider>
{useGlobalStore ? (
<GlobalStateStoreProvider fetchOnMount={false}>

102
src/Griddy/CONTEXT.md Normal file
View 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

View 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
View 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

View 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,
}),
)

View 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
}

View 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
View 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 }

View 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])
}

View 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
View 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'

View File

@@ -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

View 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>
)
}

View 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"
/>
)
}

View 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>
)
}

View 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>
)
}

View 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,
})
}

View 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);
}

View File

@@ -3,6 +3,7 @@ export * from './ErrorBoundary';
export * from './Former';
export * from './FormerControllers';
export * from './GlobalStateStore';
export * from './Griddy';
export * from './Gridler';
export {