A Griddy AI prototype
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user