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

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