refactor(types): reorganize SearchCondition and AdvancedSearchState interfaces refactor(filterPresets): streamline useFilterPresets hook and localStorage handling refactor(filtering): clean up ColumnFilterButton and ColumnFilterPopover components refactor(loading): separate GriddyLoadingOverlay from GriddyLoadingSkeleton refactor(searchHistory): enhance useSearchHistory hook with persistence refactor(index): update exports for adapters and core components refactor(rendering): improve EditableCell and TableCell components for clarity refactor(rendering): enhance TableHeader and VirtualBody components for better readability
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
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<T>(props: GriddyProps<T>, ref: Ref<GriddyRef<T>>) {
|
|
return (
|
|
<GriddyProvider {...props}>
|
|
<GriddyErrorBoundary onError={props.onError} onRetry={props.onRetry}>
|
|
<GriddyInner tableRef={ref} />
|
|
</GriddyErrorBoundary>
|
|
{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 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<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>([]);
|
|
|
|
// 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<ColumnPinningState>(initialPinning);
|
|
const [grouping, setGrouping] = useState<GroupingState>(groupingConfig?.columns ?? []);
|
|
const [expanded, setExpanded] = useState({});
|
|
const [internalPagination, setInternalPagination] = useState<PaginationState>({
|
|
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<T>({
|
|
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<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 />}
|
|
{advancedSearch?.enabled && <AdvancedSearchPanel table={table} />}
|
|
{showToolbar && (
|
|
<GridToolbar
|
|
exportFilename={exportFilename}
|
|
filterPresets={filterPresets}
|
|
persistenceKey={persistenceKey}
|
|
table={table}
|
|
/>
|
|
)}
|
|
<div
|
|
className={styles[CSS.container]}
|
|
ref={scrollRef}
|
|
style={containerStyle}
|
|
tabIndex={enableKeyboard ? 0 : undefined}
|
|
>
|
|
<TableHeader />
|
|
{isLoading && (!data || data.length === 0) ? (
|
|
<GriddyLoadingSkeleton />
|
|
) : (
|
|
<>
|
|
<VirtualBody />
|
|
{isLoading && <GriddyLoadingOverlay />}
|
|
</>
|
|
)}
|
|
</div>
|
|
{paginationConfig?.enabled && (
|
|
<PaginationControl pageSizeOptions={paginationConfig.pageSizeOptions} table={table} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const Griddy = forwardRef(_Griddy) as <T>(
|
|
props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>>
|
|
) => React.ReactElement;
|