import { type ColumnDef, type ColumnFiltersState, type ColumnOrderState, type ColumnPinningState, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getGroupedRowModel, getPaginationRowModel, getSortedRowModel, type GroupingState, 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 { advancedSearchGlobalFilterFn, AdvancedSearchPanel } from '../features/advancedSearch'; import { GriddyErrorBoundary } from '../features/errorBoundary'; import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation'; import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading'; import { PaginationControl } from '../features/pagination'; import { SearchOverlay } from '../features/search/SearchOverlay'; import { GridToolbar } from '../features/toolbar'; 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(props: GriddyProps, ref: Ref>) { return ( {props.children} ); } // ─── Main Component with forwardRef ────────────────────────────────────────── function GriddyInner({ tableRef }: { tableRef: Ref> }) { // 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 groupingConfig = useGriddyStore((s) => s.grouping); 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 controlledPinning = useGriddyStore((s) => s.columnPinning); const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange); 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 showToolbar = useGriddyStore((s) => s.showToolbar); const exportFilename = useGriddyStore((s) => s.exportFilename); const isLoading = useGriddyStore((s) => s.isLoading); const filterPresets = useGriddyStore((s) => s.filterPresets); const advancedSearch = useGriddyStore((s) => s.advancedSearch); const persistenceKey = useGriddyStore((s) => s.persistenceKey); const manualSorting = useGriddyStore((s) => s.manualSorting); const manualFiltering = useGriddyStore((s) => s.manualFiltering); const dataCount = useGriddyStore((s) => s.dataCount); const setTable = useGriddyStore((s) => s.setTable); const setVirtualizer = useGriddyStore((s) => s.setVirtualizer); const setScrollRef = useGriddyStore((s) => s.setScrollRef); 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[], [userColumns, selection] ); // ─── Table State (internal/uncontrolled) ─── const [internalSorting, setInternalSorting] = useState([]); const [internalFilters, setInternalFilters] = useState([]); const [internalRowSelection, setInternalRowSelection] = useState({}); const [globalFilter, setGlobalFilter] = useState(undefined); const [columnVisibility, setColumnVisibility] = useState({}); const [columnOrder, setColumnOrder] = useState([]); // Build initial column pinning from column definitions const initialPinning = useMemo(() => { const left: string[] = []; const right: string[] = []; userColumns?.forEach((col) => { if (col.pinned === 'left') left.push(col.id); else if (col.pinned === 'right') right.push(col.id); }); return { left, right }; }, [userColumns]); const [internalPinning, setInternalPinning] = useState(initialPinning); const [grouping, setGrouping] = useState(groupingConfig?.columns ?? []); const [expanded, setExpanded] = useState({}); const [internalPagination, setInternalPagination] = useState({ pageIndex: 0, pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize, }); // Wrap pagination setters to call callbacks const handlePaginationChange = (updater: any) => { setInternalPagination((prev) => { const next = typeof updater === 'function' ? updater(prev) : updater; // Call callbacks if pagination config exists if (paginationConfig) { if (next.pageIndex !== prev.pageIndex && paginationConfig.onPageChange) { paginationConfig.onPageChange(next.pageIndex); } if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) { paginationConfig.onPageSizeChange(next.pageSize); } } return next; }); }; // Resolve controlled vs uncontrolled const sorting = controlledSorting ?? internalSorting; const setSorting = onSortingChange ?? setInternalSorting; const columnFilters = controlledFilters ?? internalFilters; const setColumnFilters = onColumnFiltersChange ?? setInternalFilters; const columnPinning = controlledPinning ?? internalPinning; const setColumnPinning = onColumnPinningChange ?? setInternalPinning; 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({ columns, data: (data ?? []) as T[], enableColumnResizing: true, enableExpanding: true, enableFilters: true, enableGrouping: groupingConfig?.enabled ?? false, enableMultiRowSelection, enableMultiSort: true, enablePinning: true, enableRowSelection, enableSorting: true, getCoreRowModel: getCoreRowModel(), ...(advancedSearch?.enabled ? { globalFilterFn: advancedSearchGlobalFilterFn as any } : {}), getExpandedRowModel: getExpandedRowModel(), getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(), getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined, getRowId: (getRowId as any) ?? ((_, index) => String(index)), getSortedRowModel: manualSorting ? undefined : getSortedRowModel(), manualFiltering: manualFiltering ?? false, manualSorting: manualSorting ?? false, onColumnFiltersChange: setColumnFilters as any, onColumnOrderChange: setColumnOrder, onColumnPinningChange: setColumnPinning as any, onColumnVisibilityChange: setColumnVisibility, onExpandedChange: setExpanded, onGlobalFilterChange: setGlobalFilter, onGroupingChange: setGrouping, onPaginationChange: paginationConfig?.enabled ? handlePaginationChange : undefined, onRowSelectionChange: setRowSelection as any, onSortingChange: setSorting as any, rowCount: dataCount, state: { columnFilters, columnOrder, columnPinning, columnVisibility, expanded, globalFilter, grouping, rowSelection: rowSelectionState, sorting, ...(paginationConfig?.enabled ? { pagination: internalPagination } : {}), }, ...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}), columnResizeMode: 'onChange', }); // ─── Scroll Container Ref ─── const scrollRef = useRef(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 (
{search?.enabled && } {advancedSearch?.enabled && } {showToolbar && ( )}
{isLoading && (!data || data.length === 0) ? ( ) : ( <> {isLoading && } )}
{paginationConfig?.enabled && ( )}
); } export const Griddy = forwardRef(_Griddy) as ( props: GriddyProps & React.RefAttributes> ) => React.ReactElement;