mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-24 12:20:14 +01:00
feat: enhance DataTable with column pinning and improve sorting functionality
This commit is contained in:
parent
8b5f9104ef
commit
0c8598bd15
3 changed files with 298 additions and 100 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TrashIcon, MessageSquare } from 'lucide-react';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
|
|
@ -95,10 +94,6 @@ export default function SharedLinks() {
|
|||
);
|
||||
|
||||
if (validRows.length === 0) {
|
||||
showToast({
|
||||
message: localize('com_ui_no_valid_items'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -188,11 +183,7 @@ export default function SharedLinks() {
|
|||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
),
|
||||
header: () => <Label>{localize('com_assistants_actions')}</Label>,
|
||||
meta: {
|
||||
size: '7%',
|
||||
mobileSize: '25%',
|
||||
|
|
@ -255,14 +246,25 @@ export default function SharedLinks() {
|
|||
columns={columns}
|
||||
data={allLinks}
|
||||
onDelete={handleDelete}
|
||||
config={{ skeleton: { count: 10 }, search: { filterColumn: 'title' } }}
|
||||
config={{
|
||||
skeleton: { count: 10 },
|
||||
search: {
|
||||
filterColumn: 'title',
|
||||
enableSearch: true,
|
||||
debounce: 300,
|
||||
},
|
||||
selection: {
|
||||
enableRowSelection: true,
|
||||
showCheckboxes: true,
|
||||
},
|
||||
}}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
onFilterChange={handleFilterChange}
|
||||
isLoading={isLoading}
|
||||
onSortChange={handleSort}
|
||||
onSortingChange={handleSort}
|
||||
sortBy={queryParams.sortBy}
|
||||
sortDirection={queryParams.sortDirection}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -27,12 +27,15 @@ import { NotificationSeverity } from '~/common';
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { formatDate } from '~/utils';
|
||||
|
||||
const DEFAULT_PARAMS: ConversationListParams = {
|
||||
const DEFAULT_PARAMS = {
|
||||
isArchived: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
};
|
||||
} as const satisfies ConversationListParams;
|
||||
|
||||
type SortKey = 'createdAt' | 'title';
|
||||
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||
|
||||
const defaultSort: SortingState = [
|
||||
{
|
||||
|
|
@ -42,6 +45,7 @@ const defaultSort: SortingState = [
|
|||
];
|
||||
|
||||
// Define the table column type for better type safety
|
||||
// (kept from your original code)
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
|
|
@ -82,26 +86,36 @@ export default function ArchivedChatsTable() {
|
|||
}));
|
||||
}, []);
|
||||
|
||||
// Robust against stale state; keeps UI sort in sync with backend defaults
|
||||
const handleSortingChange = useCallback(
|
||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
const newSorting = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(newSorting);
|
||||
const sortDescriptor = newSorting[0];
|
||||
if (sortDescriptor) {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortDescriptor.id as 'createdAt' | 'title',
|
||||
sortDirection: sortDescriptor.desc ? 'desc' : 'asc',
|
||||
}));
|
||||
} else {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
}));
|
||||
}
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
|
||||
// If user clears sorting, fall back to default both in UI and query
|
||||
const coerced = next.length === 0 ? defaultSort : next;
|
||||
const primary = coerced[0];
|
||||
|
||||
setQueryParams((p) => {
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
return {
|
||||
...p,
|
||||
sortBy: primary.id,
|
||||
sortDirection: primary.desc ? 'desc' : 'asc',
|
||||
};
|
||||
}
|
||||
// Fallback if id isn't one of the permitted keys
|
||||
return {
|
||||
...p,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
};
|
||||
});
|
||||
|
||||
return coerced;
|
||||
});
|
||||
},
|
||||
[sorting],
|
||||
[setQueryParams, setSorting],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
|
|
@ -317,6 +331,29 @@ export default function ArchivedChatsTable() {
|
|||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_archived_chats')}
|
||||
className="w-11/12 max-w-md"
|
||||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
|
||||
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
|
||||
}`,
|
||||
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ import React, {
|
|||
memo,
|
||||
useMemo,
|
||||
startTransition,
|
||||
CSSProperties,
|
||||
} from 'react';
|
||||
import { ArrowUp, ArrowDownUp } from 'lucide-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Row,
|
||||
Column,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
|
|
@ -21,6 +23,7 @@ import {
|
|||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
ColumnPinningState,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Table as TTable } from '@tanstack/react-table';
|
||||
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
|
||||
|
|
@ -76,6 +79,61 @@ export const DATA_TABLE_CONSTANTS = {
|
|||
// Static skeleton widths for performance - avoids random computation on every render
|
||||
const STATIC_SKELETON_WIDTHS = [20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 220, 240];
|
||||
|
||||
// Column pinning utility - applies sticky positioning for pinned columns
|
||||
const getCommonPinningStyles = <TData,>(
|
||||
column: Column<TData>,
|
||||
table: TTable<TData>,
|
||||
): CSSProperties => {
|
||||
const isPinned = column.getIsPinned();
|
||||
|
||||
if (!isPinned) {
|
||||
return {
|
||||
position: 'relative',
|
||||
width: column.getSize(),
|
||||
zIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const leftPinnedColumns = table.getLeftLeafColumns();
|
||||
const rightPinnedColumns = table.getRightLeafColumns();
|
||||
|
||||
const isLastLeftPinnedColumn =
|
||||
isPinned === 'left' &&
|
||||
leftPinnedColumns.length > 0 &&
|
||||
leftPinnedColumns[leftPinnedColumns.length - 1].id === column.id;
|
||||
|
||||
const isFirstRightPinnedColumn =
|
||||
isPinned === 'right' && rightPinnedColumns.length > 0 && rightPinnedColumns[0].id === column.id;
|
||||
|
||||
let boxShadow: string | undefined;
|
||||
if (isLastLeftPinnedColumn) {
|
||||
boxShadow = '-4px 0 4px -4px rgba(0, 0, 0, 0.1) inset';
|
||||
} else if (isFirstRightPinnedColumn) {
|
||||
boxShadow = '4px 0 4px -4px rgba(0, 0, 0, 0.1) inset';
|
||||
}
|
||||
|
||||
// Calculate offset for pinned position
|
||||
let offset = 0;
|
||||
if (isPinned === 'left') {
|
||||
const columnIndex = leftPinnedColumns.findIndex((col) => col.id === column.id);
|
||||
offset = leftPinnedColumns.slice(0, columnIndex).reduce((sum, col) => sum + col.getSize(), 0);
|
||||
} else if (isPinned === 'right') {
|
||||
const columnIndex = rightPinnedColumns.findIndex((col) => col.id === column.id);
|
||||
offset = rightPinnedColumns.slice(columnIndex + 1).reduce((sum, col) => sum + col.getSize(), 0);
|
||||
}
|
||||
|
||||
return {
|
||||
boxShadow,
|
||||
left: isPinned === 'left' ? `${offset}px` : undefined,
|
||||
right: isPinned === 'right' ? `${offset}px` : undefined,
|
||||
opacity: 0.95,
|
||||
position: 'sticky',
|
||||
width: column.getSize(),
|
||||
zIndex: 1,
|
||||
backgroundColor: 'var(--surface-secondary)',
|
||||
};
|
||||
};
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
|
|
@ -84,12 +142,12 @@ type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
|||
};
|
||||
};
|
||||
|
||||
// Throttle utility for performance optimization
|
||||
const throttle = <T extends (...args: any[]) => any>(fn: T, delay: number): T => {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
// Throttle utility for performance optimization - Fixed: Use DOM-safe timeout type
|
||||
const throttle = <T extends (...args: unknown[]) => unknown>(fn: T, delay: number): T => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastExecTime = 0;
|
||||
|
||||
return ((...args: any[]) => {
|
||||
return ((...args: Parameters<T>) => {
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime - lastExecTime > delay) {
|
||||
|
|
@ -109,18 +167,21 @@ const throttle = <T extends (...args: any[]) => any>(fn: T, delay: number): T =>
|
|||
};
|
||||
|
||||
// Deep comparison utility for objects
|
||||
const deepEqual = (a: any, b: any): boolean => {
|
||||
const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (typeof a === 'object') {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
const keysA = Object.keys(a as Record<string, unknown>);
|
||||
const keysB = Object.keys(b as Record<string, unknown>);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key) || !deepEqual(a[key], b[key])) {
|
||||
if (
|
||||
!keysB.includes(key) ||
|
||||
!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -187,14 +248,16 @@ const useColumnStyles = <TData, TValue>(
|
|||
}, [columns, isSmallScreen]);
|
||||
};
|
||||
|
||||
const TableRowComponent = <TData, TValue>({
|
||||
const TableRowComponent = <TData,>({
|
||||
row,
|
||||
columnStyles,
|
||||
table,
|
||||
index,
|
||||
virtualIndex,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
columnStyles: Record<string, React.CSSProperties>;
|
||||
table: TTable<TData>;
|
||||
index: number;
|
||||
virtualIndex?: number;
|
||||
}) => {
|
||||
|
|
@ -230,7 +293,10 @@ const TableRowComponent = <TData, TValue>({
|
|||
<TableCell
|
||||
key={cell.id}
|
||||
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-colors duration-200 sm:px-4 sm:py-2 sm:text-sm"
|
||||
style={columnStyles[cell.column.id] || {}}
|
||||
style={{
|
||||
...columnStyles[cell.column.id],
|
||||
...getCommonPinningStyles(cell.column, table),
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{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<T>(value: T, delay: number) {
|
||||
|
|
@ -523,6 +605,7 @@ export default function DataTable<TData, TValue>({
|
|||
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<TData, TValue>({
|
|||
skeletonCount,
|
||||
overscan,
|
||||
debounceDelay,
|
||||
enableColumnPinning,
|
||||
} = tableConfig;
|
||||
|
||||
const localize = useLocalize();
|
||||
|
|
@ -543,6 +627,7 @@ export default function DataTable<TData, TValue>({
|
|||
// State management
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({});
|
||||
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
|
||||
const [term, setTerm] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
|
@ -563,6 +648,9 @@ export default function DataTable<TData, TValue>({
|
|||
const [internalSorting, setInternalSorting] = useState<SortingState>(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<TData, TValue>({
|
|||
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<TData, TValue>({
|
|||
}, [columns, enableRowSelection, showCheckboxes]);
|
||||
|
||||
// Memoized column styles for performance
|
||||
const columnStyles = useColumnStyles(tableColumns, isSmallScreen);
|
||||
const columnStyles = useColumnStyles(tableColumns as TableColumn<TData, TValue>[], 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<TData, TValue>({
|
|||
return data.map((item, index) => ({
|
||||
...item,
|
||||
_index: index,
|
||||
_id: (item as any)?.id || index,
|
||||
_id: (item as Record<string, unknown>)?.id || index,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
|
|
@ -692,31 +787,25 @@ export default function DataTable<TData, TValue>({
|
|||
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<number[]>([]);
|
||||
|
|
@ -732,23 +821,26 @@ export default function DataTable<TData, TValue>({
|
|||
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<TData, TValue>({
|
|||
[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<TData, TValue>({
|
|||
}, [rowVirtualizer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
|
|
@ -832,8 +924,6 @@ export default function DataTable<TData, TValue>({
|
|||
};
|
||||
}, [rowVirtualizer, handleWindowResize, throttledHandleScroll]);
|
||||
|
||||
// Mount tracking for cleanup
|
||||
const mountedRef = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
|
|
@ -843,6 +933,8 @@ export default function DataTable<TData, TValue>({
|
|||
|
||||
// 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<TData, TValue>({
|
|||
isSmallScreen,
|
||||
virtualRowsWithStableKeys.length,
|
||||
virtualRowsWithStableKeys,
|
||||
virtualRows.length,
|
||||
]);
|
||||
|
||||
// Search effect with optimized state updates
|
||||
|
|
@ -899,6 +992,7 @@ export default function DataTable<TData, TValue>({
|
|||
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<TData, TValue>({
|
|||
(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<TData, TValue>({
|
|||
}
|
||||
}, [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<TData, TValue>({
|
|||
role="region"
|
||||
aria-label="Data table"
|
||||
>
|
||||
{/* Accessible live region for loading announcements */}
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{isFetchingNextPage
|
||||
? 'Loading more rows'
|
||||
: hasNextPage
|
||||
? 'More rows available'
|
||||
: 'All rows loaded'}
|
||||
</div>
|
||||
|
||||
{/* Error display - kept outside boundary for non-rendering errors */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/20 dark:text-red-400">
|
||||
|
|
@ -1030,9 +1130,10 @@ export default function DataTable<TData, TValue>({
|
|||
>
|
||||
<Table
|
||||
className={`w-full min-w-[${DATA_TABLE_CONSTANTS.TABLE_MIN_WIDTH}] table-fixed border-separate border-spacing-0`}
|
||||
style={{ borderCollapse: 'separate' }}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-50 bg-surface-secondary backdrop-blur-sm">
|
||||
{memoizedHeaderGroups.map((headerGroup) => (
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light" role="row">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sortDir = header.column.getIsSorted();
|
||||
|
|
@ -1053,12 +1154,26 @@ export default function DataTable<TData, TValue>({
|
|||
<TableHead
|
||||
key={header.id}
|
||||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||
// Fixed: Add keyboard activation for sorting
|
||||
onKeyDown={
|
||||
canSort
|
||||
? (e) => {
|
||||
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<TData, TValue>({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Column pinning controls */}
|
||||
{enableColumnPinning &&
|
||||
!header.isPlaceholder &&
|
||||
header.column.getCanPin() && (
|
||||
<div className="mt-1 flex justify-center gap-1">
|
||||
{header.column.getIsPinned() !== 'left' && (
|
||||
<button
|
||||
className="rounded border px-1 text-xs hover:bg-surface-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
header.column.pin('left');
|
||||
}}
|
||||
title="Pin to left"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
{header.column.getIsPinned() && (
|
||||
<button
|
||||
className="rounded border px-1 text-xs hover:bg-surface-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
header.column.pin(false);
|
||||
}}
|
||||
title="Unpin"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{header.column.getIsPinned() !== 'right' && (
|
||||
<button
|
||||
className="rounded border px-1 text-xs hover:bg-surface-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
header.column.pin('right');
|
||||
}}
|
||||
title="Pin to right"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1109,6 +1267,7 @@ export default function DataTable<TData, TValue>({
|
|||
key={virtualRow.stableKey}
|
||||
row={row}
|
||||
columnStyles={columnStyles}
|
||||
table={table}
|
||||
index={virtualRow.index}
|
||||
virtualIndex={virtualRow.index}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue