{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -345,11 +411,13 @@ SkeletonRows.displayName = 'SkeletonRows';
* selection: { enableRowSelection: true, showCheckboxes: false },
* search: { enableSearch: true, debounce: 500, filterColumn: 'name' },
* skeleton: { count: 5 },
- * virtualization: { overscan: 15 }
+ * virtualization: { overscan: 15 },
+ * pinning: { enableColumnPinning: true }
* };
*
* Defaults: enableRowSelection: true, showCheckboxes: true, enableSearch: true,
- * skeleton.count: 10, virtualization.overscan: 10, search.debounce: 300
+ * skeleton.count: 10, virtualization.overscan: 10, search.debounce: 300,
+ * pinning.enableColumnPinning: false
*/
interface DataTableConfig {
/**
@@ -428,6 +496,20 @@ interface DataTableConfig {
*/
overscan?: number;
};
+
+ /**
+ * Column pinning configuration for sticky column behavior.
+ * Controls whether columns can be pinned to left or right side of the table.
+ */
+ pinning?: {
+ /**
+ * Enable column pinning functionality.
+ * When true, columns can be pinned to the left or right side of the table.
+ * Pinned columns remain visible during horizontal scrolling.
+ * @default false
+ */
+ enableColumnPinning?: boolean;
+ };
}
function useDebounced
(value: T, delay: number) {
@@ -523,6 +605,7 @@ export default function DataTable({
skeletonCount: config?.skeleton?.count ?? 10,
overscan: config?.virtualization?.overscan ?? DATA_TABLE_CONSTANTS.OVERS_CAN,
debounceDelay: config?.search?.debounce ?? DATA_TABLE_CONSTANTS.SEARCH_DEBOUNCE_MS,
+ enableColumnPinning: config?.pinning?.enableColumnPinning ?? false,
};
}, [config]);
@@ -534,6 +617,7 @@ export default function DataTable({
skeletonCount,
overscan,
debounceDelay,
+ enableColumnPinning,
} = tableConfig;
const localize = useLocalize();
@@ -543,6 +627,7 @@ export default function DataTable({
// State management
const [columnFilters, setColumnFilters] = useState([]);
const [columnVisibility, setColumnVisibility] = useState({});
+ const [columnPinning, setColumnPinning] = useState({});
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
const [term, setTerm] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
@@ -563,6 +648,9 @@ export default function DataTable({
const [internalSorting, setInternalSorting] = useState(defaultSort);
const finalSorting = sorting ?? internalSorting;
+ // Mount tracking for cleanup - Fixed: Declare mountedRef before any callback that uses it
+ const mountedRef = useRef(true);
+
// Sorting handler: call external callback if provided, otherwise use internal state
const handleSortingChangeInternal = useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
@@ -611,6 +699,13 @@ export default function DataTable({
filterValue,
sortingLength: sorting?.length || 0,
};
+
+ // Search UX warning for missing filterColumn
+ if (enableSearch && !filterColumn && onFilterChange) {
+ console.warn(
+ 'DataTable: enableSearch is true but filterColumn is missing. Search will be hidden.',
+ );
+ }
}
const sanitizeError = useCallback((err: Error): string => {
@@ -660,18 +755,18 @@ export default function DataTable({
}, [columns, enableRowSelection, showCheckboxes]);
// Memoized column styles for performance
- const columnStyles = useColumnStyles(tableColumns, isSmallScreen);
+ const columnStyles = useColumnStyles(tableColumns as TableColumn[], isSmallScreen);
- // Set CSS variables for column sizing
+ // Set CSS variables for column sizing - Fixed: Add SSR guard
useLayoutEffect(() => {
- if (tableContainerRef.current) {
- tableColumns.forEach((column, index) => {
- if (column.id) {
- const size = columnStyles[column.id]?.width || 'auto';
- tableContainerRef.current!.style.setProperty(`--col-${index}-size`, `${size}`);
- }
- });
- }
+ if (typeof window === 'undefined' || !tableContainerRef.current) return;
+
+ tableColumns.forEach((column, index) => {
+ if (column.id) {
+ const size = columnStyles[column.id]?.width || 'auto';
+ tableContainerRef.current!.style.setProperty(`--col-${index}-size`, `${size}`);
+ }
+ });
}, [tableColumns, columnStyles]);
// Memoized row data with stable references
@@ -679,7 +774,7 @@ export default function DataTable({
return data.map((item, index) => ({
...item,
_index: index,
- _id: (item as any)?.id || index,
+ _id: (item as Record)?.id || index,
}));
}, [data]);
@@ -692,31 +787,25 @@ export default function DataTable({
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection,
enableMultiRowSelection: true,
+ enableColumnPinning,
state: {
sorting: finalSorting,
columnFilters,
columnVisibility,
+ columnPinning,
rowSelection: optimizedRowSelection,
},
onSortingChange: handleSortingChange,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
+ onColumnPinningChange: setColumnPinning,
onRowSelectionChange: setOptimizedRowSelection,
});
const { rows } = table.getRowModel();
- // Memoized header groups for performance
- const memoizedHeaderGroups = useMemo(
- () => table.getHeaderGroups(),
- [
- table
- .getAllColumns()
- .map((c) => c.id)
- .join(','),
- finalSorting,
- ],
- );
+ // Fixed: Simplify header groups - React Table already memoizes internally
+ const headerGroups = table.getHeaderGroups();
// Virtual scrolling setup with optimized height measurement
const measuredHeightsRef = useRef([]);
@@ -732,23 +821,26 @@ export default function DataTable({
return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE;
}, []),
overscan,
+ // Fixed: Optimize measureElement to avoid duplicate getBoundingClientRect calls
measureElement: (el) => {
- if (el) {
- const height = el.getBoundingClientRect().height;
- // Memory management for measured heights
- measuredHeightsRef.current = [
- ...measuredHeightsRef.current.slice(-DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM + 1),
- height,
- ];
+ if (!el) return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE;
- // Trim if exceeds max
- if (measuredHeightsRef.current.length > DATA_TABLE_CONSTANTS.MAX_MEASURED_HEIGHTS) {
- measuredHeightsRef.current = measuredHeightsRef.current.slice(
- -DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM,
- );
- }
+ const height = el.getBoundingClientRect().height;
+
+ // Memory management for measured heights
+ measuredHeightsRef.current = [
+ ...measuredHeightsRef.current.slice(-DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM + 1),
+ height,
+ ];
+
+ // Trim if exceeds max
+ if (measuredHeightsRef.current.length > DATA_TABLE_CONSTANTS.MAX_MEASURED_HEIGHTS) {
+ measuredHeightsRef.current = measuredHeightsRef.current.slice(
+ -DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM,
+ );
}
- return el?.getBoundingClientRect().height ?? DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE;
+
+ return height;
},
});
@@ -768,21 +860,19 @@ export default function DataTable({
[virtualRows, rows],
);
- // Infinite scrolling with throttled handler
+ // Fixed: Infinite scrolling with simplified trigger logic and removed accidental local mountedRef
const handleScrollInternal = useCallback(async () => {
- const mountedRef = { current: true };
if (!mountedRef.current || !tableContainerRef.current) return;
const scrollElement = tableContainerRef.current;
- const element = scrollElement.getBoundingClientRect();
- const scrollTop = scrollElement.scrollTop;
const clientHeight = scrollElement.clientHeight;
const virtualEnd = virtualRows.length > 0 ? virtualRows[virtualRows.length - 1].end : 0;
- if (
- totalSize - virtualEnd <= clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD ||
- (element.bottom - window.innerHeight < 100 && scrollTop + clientHeight >= totalSize - 100)
- ) {
+ // Simplified condition: check distance to virtual end
+ const nearEnd =
+ totalSize - virtualEnd <= clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD;
+
+ if (nearEnd) {
try {
await fetchNextPage?.();
} catch (err) {
@@ -814,6 +904,8 @@ export default function DataTable({
}, [rowVirtualizer]);
useEffect(() => {
+ if (typeof window === 'undefined') return;
+
const scrollElement = tableContainerRef.current;
if (!scrollElement) return;
@@ -832,8 +924,6 @@ export default function DataTable({
};
}, [rowVirtualizer, handleWindowResize, throttledHandleScroll]);
- // Mount tracking for cleanup
- const mountedRef = useRef(true);
useLayoutEffect(() => {
mountedRef.current = true;
return () => {
@@ -843,6 +933,8 @@ export default function DataTable({
// Dynamic measurement optimization
useLayoutEffect(() => {
+ if (typeof window === 'undefined') return;
+
if (mountedRef.current && tableContainerRef.current && virtualRows.length > 0) {
requestAnimationFrame(() => {
if (mountedRef.current) {
@@ -865,6 +957,7 @@ export default function DataTable({
isSmallScreen,
virtualRowsWithStableKeys.length,
virtualRowsWithStableKeys,
+ virtualRows.length,
]);
// Search effect with optimized state updates
@@ -899,6 +992,7 @@ export default function DataTable({
setError(null);
try {
+ const selectedRowsLength = table.getFilteredSelectedRowModel().rows.length;
let selectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
// Validation
@@ -912,10 +1006,7 @@ export default function DataTable({
(row): row is TData => typeof row === 'object' && row !== null,
);
- if (
- selectedRows.length !== table.getFilteredSelectedRowModel().rows.length &&
- process.env.NODE_ENV === 'development'
- ) {
+ if (selectedRows.length !== selectedRowsLength && process.env.NODE_ENV === 'development') {
console.warn('DataTable: Invalid row data detected and filtered out during deletion.');
}
@@ -947,11 +1038,11 @@ export default function DataTable({
}
}, [onReset, rowVirtualizer]);
- // Memoized computed values
- const selectedCount = useMemo(
- () => table.getFilteredSelectedRowModel().rows.length,
- [table.getFilteredSelectedRowModel().rows.length],
- );
+ // Fixed: Derive selected count from stable table state instead of re-calling getFilteredSelectedRowModel
+ const selectedCount = useMemo(() => {
+ const selection = table.getState().rowSelection;
+ return Object.keys(selection).length;
+ }, [table.getState().rowSelection]);
const shouldShowSearch = useMemo(
() => enableSearch && filterColumn && table.getColumn(filterColumn),
@@ -971,6 +1062,15 @@ export default function DataTable({
role="region"
aria-label="Data table"
>
+ {/* Accessible live region for loading announcements */}
+
+ {isFetchingNextPage
+ ? 'Loading more rows'
+ : hasNextPage
+ ? 'More rows available'
+ : 'All rows loaded'}
+
+
{/* Error display - kept outside boundary for non-rendering errors */}
{error && (
@@ -1030,9 +1130,10 @@ export default function DataTable
({
>
- {memoizedHeaderGroups.map((headerGroup) => (
+ {headerGroups.map((headerGroup) => (
{headerGroup.headers.map((header) => {
const sortDir = header.column.getIsSorted();
@@ -1053,12 +1154,26 @@ export default function DataTable({
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ header.column.toggleSorting();
+ }
+ }
+ : undefined
+ }
className={cn(
'relative whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary transition-colors duration-200 sm:px-4',
canSort &&
'cursor-pointer hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
)}
- style={columnStyles[header.column.id] || {}}
+ style={{
+ ...columnStyles[header.column.id],
+ ...getCommonPinningStyles(header.column, table),
+ }}
role="columnheader"
tabIndex={canSort ? 0 : -1}
aria-sort={ariaSort}
@@ -1081,6 +1196,49 @@ export default function DataTable({
)}
+ {/* Column pinning controls */}
+ {enableColumnPinning &&
+ !header.isPlaceholder &&
+ header.column.getCanPin() && (
+
+ {header.column.getIsPinned() !== 'left' && (
+
+ )}
+ {header.column.getIsPinned() && (
+
+ )}
+ {header.column.getIsPinned() !== 'right' && (
+
+ )}
+
+ )}
);
})}
@@ -1109,6 +1267,7 @@ export default function DataTable({
key={virtualRow.stableKey}
row={row}
columnStyles={columnStyles}
+ table={table}
index={virtualRow.index}
virtualIndex={virtualRow.index}
/>