diff --git a/src/components/animate/Animate.tsx b/src/components/animate/Animate.tsx index 39cbb0539..1f63bb4a3 100644 --- a/src/components/animate/Animate.tsx +++ b/src/components/animate/Animate.tsx @@ -7,7 +7,7 @@ import { calculateBufferRowCount, ROW_SEPARATOR_WIDTH } from "../../consts/gener // Animation thresholds const COLUMN_REORDER_THRESHOLD = 50; // px - minimum horizontal movement to trigger column reorder animation -const ROW_REORDER_THRESHOLD = 5; // px - minimum vertical movement to trigger row reorder animation +const ROW_REORDER_THRESHOLD = 10; // px - minimum vertical movement to trigger row reorder animation const DOM_POSITION_CHANGE_THRESHOLD = 5; // px - minimum position change to detect DOM movement // Stagger configuration @@ -31,12 +31,15 @@ interface AnimateProps extends Omit, "id"> tableRow?: TableRow; } export const Animate = ({ children, id, parentRef, tableRow, ...props }: AnimateProps) => { - const { allowAnimations, isResizing, isScrolling, rowHeight } = useTableContext(); + const { allowAnimations, capturedPositionsRef, isResizing, isScrolling, rowHeight } = + useTableContext(); const elementRef = useRef(null); const fromBoundsRef = useRef(null); const previousScrollingState = usePrevious(isScrolling); const previousResizingState = usePrevious(isResizing); + const cleanupCallbackRef = useRef<(() => void) | null>(null); const bufferRowCount = useMemo(() => calculateBufferRowCount(rowHeight), [rowHeight]); + useLayoutEffect(() => { // Early exit if animations are disabled - don't do any work at all if (!allowAnimations) { @@ -48,8 +51,12 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate return; } - const toBounds = elementRef.current.getBoundingClientRect(); - const fromBounds = fromBoundsRef.current; + let toBounds = elementRef.current.getBoundingClientRect(); + + // CRITICAL: Check if we have a captured position for this element (react-flip-move pattern) + // This allows animations to continue smoothly even when interrupted by rapid clicks + const capturedPosition = capturedPositionsRef.current.get(id); + const fromBounds = capturedPosition || fromBoundsRef.current; // If we're currently scrolling, don't animate and don't update bounds if (isScrolling) { @@ -65,12 +72,18 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate // If resizing just ended, update the previous bounds without animating if (previousResizingState && !isResizing) { fromBoundsRef.current = toBounds; + capturedPositionsRef.current.delete(id); return; } // Store current bounds for next render fromBoundsRef.current = toBounds; + // Clear captured position after using it (it's been consumed) + if (capturedPosition) { + capturedPositionsRef.current.delete(id); + } + // If there's no previous bound data, don't animate (prevents first render animations) if (!fromBounds) { return; @@ -96,12 +109,51 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate hasPositionChanged = hasDOMPositionChanged; if (hasPositionChanged) { + // CRITICAL: Cancel any pending cleanup from the previous animation + // This prevents the old animation's cleanup from interfering with the new one + if (cleanupCallbackRef.current) { + cleanupCallbackRef.current(); + cleanupCallbackRef.current = null; + } + + // CRITICAL: Immediately stop any in-progress animation before starting a new one + // This prevents the old animation from interfering with position calculations + if (elementRef.current.style.transition) { + // Get current visual position (with transform applied) + const currentVisualY = elementRef.current.getBoundingClientRect().y; + + // Get the pure DOM position without any transforms + // Temporarily remove transform to get true DOM position + elementRef.current.style.transform = "none"; + elementRef.current.style.transition = "none"; + const pureDOMY = elementRef.current.getBoundingClientRect().y; + + // Calculate offset needed to keep element at current visual position + const offsetY = currentVisualY - pureDOMY; + + // Set the frozen transform to keep element at current visual position + elementRef.current.style.transform = `translate3d(0px, ${offsetY}px, 0px)`; + + // Force reflow to ensure the freeze is applied + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + elementRef.current.offsetHeight; + + // CRITICAL: Recapture toBounds after freezing the element + // The DOM position has changed (it's now at pureDOMY), so we need to update toBounds + toBounds = elementRef.current.getBoundingClientRect(); + } + // Merge animation config with defaults const finalConfig = { ...ANIMATION_CONFIGS.ROW_REORDER, onComplete: () => { - // Reset z-index after animation completes + // CRITICAL: Update fromBoundsRef to final position BEFORE resetting styles + // This prevents the next useLayoutEffect from seeing a stale position if (elementRef.current) { + const finalBounds = elementRef.current.getBoundingClientRect(); + fromBoundsRef.current = finalBounds; + + // Reset z-index after animation completes elementRef.current.style.zIndex = ""; elementRef.current.style.position = ""; elementRef.current.style.top = ""; @@ -284,16 +336,21 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate } } + // Start the animation and store the cleanup function flipElement({ element: elementRef.current, fromBounds, toBounds, finalConfig, + }).then((cleanup) => { + cleanupCallbackRef.current = cleanup; }); } else { } }, [ + id, allowAnimations, + capturedPositionsRef, bufferRowCount, isResizing, isScrolling, diff --git a/src/components/animate/animation-utils.ts b/src/components/animate/animation-utils.ts index 2bfad1556..7bc826eca 100644 --- a/src/components/animate/animation-utils.ts +++ b/src/components/animate/animation-utils.ts @@ -1,4 +1,3 @@ -import CellValue from "../../types/CellValue"; import { AnimationConfig, FlipAnimationOptions, CustomAnimationOptions } from "./types"; /** @@ -13,27 +12,12 @@ export const prefersReducedMotion = (): boolean => { * Animation configs for different types of movements */ export const ANIMATION_CONFIGS = { - // For column reordering (horizontal movement) - COLUMN_REORDER: { - // duration: 3000, - duration: 180, - easing: "cubic-bezier(0.2, 0.0, 0.2, 1)", - delay: 0, - }, // For row reordering (vertical movement) ROW_REORDER: { - // duration: 3000, - duration: 200, + duration: 3000, easing: "cubic-bezier(0.2, 0.0, 0.2, 1)", delay: 0, }, - // For reduced motion users - REDUCED_MOTION: { - // duration: 3000, - duration: 150, // Even faster for reduced motion - easing: "ease-out", - delay: 0, - }, } as const; /** @@ -42,9 +26,7 @@ export const ANIMATION_CONFIGS = { export const createAnimationConfig = ( overrides: Partial = {} ): AnimationConfig => { - const baseConfig = prefersReducedMotion() - ? ANIMATION_CONFIGS.REDUCED_MOTION - : ANIMATION_CONFIGS.ROW_REORDER; // Default to row reorder as it's more common in tables + const baseConfig = ANIMATION_CONFIGS.ROW_REORDER; // Default to row reorder as it's more common in tables return { ...baseConfig, ...overrides }; }; @@ -70,14 +52,36 @@ export const calculateInvert = ( /** * Applies initial transform to element for FLIP animation + * Handles interrupting in-progress animations by adjusting the transform calculation */ export const applyInitialTransform = (element: HTMLElement, invert: { x: number; y: number }) => { - element.style.transform = `translate3d(${invert.x}px, ${invert.y}px, 0)`; + const currentVisualY = element.getBoundingClientRect().y; + const hasExistingTransform = element.style.transform && element.style.transform !== "none"; + + // If element has a frozen transform from an interrupted animation, + // we need to recalculate invert from pure DOM position + let adjustedInvertX = invert.x; + let adjustedInvertY = invert.y; + + if (hasExistingTransform) { + // Temporarily remove transform to get pure DOM position + element.style.transform = "none"; + const pureDOMY = element.getBoundingClientRect().y; + const pureDOMX = element.getBoundingClientRect().x; + + // Recalculate invert from pure DOM position + // fromBounds represents where we want to visually appear to come from + const fromBoundsY = currentVisualY + invert.y; + const fromBoundsX = element.getBoundingClientRect().x + invert.x; + + adjustedInvertY = fromBoundsY - pureDOMY; + adjustedInvertX = fromBoundsX - pureDOMX; + } + + element.style.transform = `translate3d(${adjustedInvertX}px, ${adjustedInvertY}px, 0)`; element.style.transition = "none"; - // Performance optimizations for smoother animations - element.style.willChange = "transform"; // Hint to browser for optimization - element.style.backfaceVisibility = "hidden"; // Prevent flickering during animation - // Add animating class to ensure proper z-index during animation + element.style.willChange = "transform"; + element.style.backfaceVisibility = "hidden"; element.classList.add("st-animating"); }; @@ -98,13 +102,13 @@ const cleanupAnimation = (element: HTMLElement) => { /** * Animates element to its final position + * Returns a Promise that resolves to a cleanup function that can cancel the animation */ const animateToFinalPosition = ( element: HTMLElement, config: AnimationConfig, - options: FlipAnimationOptions = {}, - id?: CellValue -): Promise => { + options: FlipAnimationOptions = {} +): Promise<() => void> => { return new Promise((resolve) => { // Force a reflow to ensure the initial transform is applied // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -121,45 +125,53 @@ const animateToFinalPosition = ( // Animate to final position element.style.transform = "translate3d(0, 0, 0)"; + let isCleanedUp = false; + let timeoutId: ReturnType | null = null; + // Clean up after animation - const cleanup = () => { + const doCleanup = () => { + if (isCleanedUp) return; + isCleanedUp = true; + cleanupAnimation(element); - element.removeEventListener("transitionend", cleanup); + element.removeEventListener("transitionend", doCleanup); + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } if (options.onComplete) { options.onComplete(); } - resolve(); }; - element.addEventListener("transitionend", cleanup); + element.addEventListener("transitionend", doCleanup); // Fallback timeout in case transitionend doesn't fire - setTimeout(cleanup, config.duration + (config.delay || 0) + 50); + timeoutId = setTimeout(doCleanup, config.duration + (config.delay || 0) + 50); + + // Return cleanup function that can cancel the animation + const cancelCleanup = () => { + if (isCleanedUp) return; + isCleanedUp = true; + + element.removeEventListener("transitionend", doCleanup); + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + resolve(cancelCleanup); }); }; /** - * Get appropriate animation config based on movement type and user preferences + * Get appropriate animation config based on user preferences */ -export const getAnimationConfig = ( - options: FlipAnimationOptions = {}, - movementType?: "column" | "row" -): AnimationConfig => { - // Check for user's motion preferences first - if (prefersReducedMotion()) { - return { ...ANIMATION_CONFIGS.REDUCED_MOTION, ...options }; - } - - // Use specific config based on movement type - if (movementType === "column") { - return { ...ANIMATION_CONFIGS.COLUMN_REORDER, ...options }; - } - if (movementType === "row") { - return { ...ANIMATION_CONFIGS.ROW_REORDER, ...options }; - } - - // Fall back to default config +export const getAnimationConfig = (options: FlipAnimationOptions = {}): AnimationConfig => { + // Use row reorder config as default return { ...ANIMATION_CONFIGS.ROW_REORDER, ...options }; }; @@ -167,6 +179,7 @@ export const getAnimationConfig = ( * Performs FLIP animation on a single element * This function can be called multiple times on the same element - it will automatically * interrupt any ongoing animation and start a new one. + * Returns a cleanup function that can be called to cancel the animation. */ export const flipElement = async ({ element, @@ -178,34 +191,39 @@ export const flipElement = async ({ finalConfig: FlipAnimationOptions; fromBounds: DOMRect; toBounds: DOMRect; -}): Promise => { +}): Promise<() => void> => { const invert = calculateInvert(fromBounds, toBounds); // Skip animation if element hasn't moved if (invert.x === 0 && invert.y === 0) { - return; + // Still need to clean up if there was an animation in progress + cleanupAnimation(element); + // Return no-op cleanup function + return () => {}; } // Skip animation entirely if user prefers reduced motion and no explicit override if (prefersReducedMotion() && finalConfig.respectReducedMotion !== false) { - return; + cleanupAnimation(element); + return () => {}; } - // Determine movement type based on the invert values - const isColumnMovement = Math.abs(invert.x) > Math.abs(invert.y); - const movementType = isColumnMovement ? "column" : "row"; + // Get appropriate config based on user preferences + const config = getAnimationConfig(finalConfig); - // Get appropriate config based on movement type and user preferences - const config = getAnimationConfig(finalConfig, movementType); + // Apply new transform BEFORE cleaning up old one to prevent flickering + // This ensures there's no gap where the element snaps to its DOM position + applyInitialTransform(element, invert); - // Clean up any existing animation before starting a new one - cleanupAnimation(element); + // Now safe to clean up transition properties (but transform is already set above) + // Only clean transition-related properties, not transform + element.style.transition = ""; + element.style.transitionDelay = ""; - // Apply initial transform with limited values - applyInitialTransform(element, invert); + // Animate to final position and get cleanup function + const cleanup = await animateToFinalPosition(element, config, finalConfig); - // Animate to final position - await animateToFinalPosition(element, config, finalConfig); + return cleanup; }; /** @@ -251,21 +269,17 @@ export const animateWithCustomCoordinates = async ({ const endTransformY = endY - currentY; return new Promise((resolve) => { - // Clean up any existing animation - element.style.transition = ""; - element.style.transitionDelay = ""; - element.style.transform = ""; - element.style.willChange = ""; - element.style.backfaceVisibility = ""; - element.classList.remove("st-animating"); - - // Set initial position (startY) + // CRITICAL: Set initial position BEFORE cleaning up to prevent flickering + // Apply new transform first (overrides any existing transform) element.style.transform = `translate3d(0, ${startTransformY}px, 0)`; - element.style.transition = "none"; element.style.willChange = "transform"; element.style.backfaceVisibility = "hidden"; element.classList.add("st-animating"); + // Now clean up transition properties (transform already set above) + element.style.transition = "none"; + element.style.transitionDelay = ""; + // Force reflow // eslint-disable-next-line @typescript-eslint/no-unused-expressions element.offsetHeight; diff --git a/src/components/simple-table/RenderCells.tsx b/src/components/simple-table/RenderCells.tsx index 67b4b277f..dde56bf8d 100644 --- a/src/components/simple-table/RenderCells.tsx +++ b/src/components/simple-table/RenderCells.tsx @@ -90,10 +90,14 @@ const RecursiveRenderCells = ({ // Get the column index for this header from our pre-calculated mapping const colIndex = columnIndices[header.accessor]; - // Get selection state for this cell + // Get context values for cell selection and dynamic state const { getBorderClass, isSelected, isInitialFocusedCell, rowIdAccessor, collapsedHeaders } = useTableContext(); + // Get only dynamic values to pass to TableCell + const { expandAll, isLoading, rowsWithSelectedCells, selectedColumns, unexpandedRows } = + useTableContext(); + // Calculate rowId once at the beginning (includes path for nested rows) const rowId = getRowId({ row: tableRow.row, @@ -101,6 +105,15 @@ const RecursiveRenderCells = ({ rowPath: tableRow.rowPath, }); + // Create props object with only dynamic values to pass to TableCell + const tableCellDynamicProps = { + expandAll, + isLoading: isLoading ?? false, + rowsWithSelectedCells, + selectedColumns, + unexpandedRows, + }; + if (header.children && header.children.length > 0) { const filteredChildren = header.children.filter((child) => displayCell({ header: child, pinned, headers, collapsedHeaders }) @@ -129,6 +142,7 @@ const RecursiveRenderCells = ({ parentHeader={parentHeader} rowIndex={rowIndex} tableRow={tableRow} + {...tableCellDynamicProps} /> {filteredChildren.map((child) => { const childCellKey = getCellId({ accessor: child.accessor, rowId }); @@ -198,6 +212,7 @@ const RecursiveRenderCells = ({ parentHeader={parentHeader} rowIndex={rowIndex} tableRow={tableRow} + {...tableCellDynamicProps} /> ); }; diff --git a/src/components/simple-table/SimpleTable.tsx b/src/components/simple-table/SimpleTable.tsx index e0a6cd86e..49ba4c2a5 100644 --- a/src/components/simple-table/SimpleTable.tsx +++ b/src/components/simple-table/SimpleTable.tsx @@ -59,6 +59,7 @@ import { EmptyStateRenderer, } from "../../types/RowStateRendererProps"; import DefaultEmptyState from "../empty-state/DefaultEmptyState"; +import { ANIMATION_CONFIGS } from "../animate/animation-utils"; interface SimpleTableProps { allowAnimations?: boolean; // Flag for allowing animations @@ -225,6 +226,7 @@ const SimpleTableComp = ({ // Refs const draggedHeaderRef = useRef(null); const hoveredHeaderRef = useRef(null); + const capturedPositionsRef = useRef>(new Map()); const mainBodyRef = useRef(null); const pinnedLeftRef = useRef(null); @@ -571,6 +573,19 @@ const SimpleTableComp = ({ onFilterChange, }); + // Function to capture all element positions (called right before sort state change) + const captureAllPositions = useCallback(() => { + const allElements = document.querySelectorAll("[data-animate-id]"); + capturedPositionsRef.current.clear(); + allElements.forEach((el) => { + const id = el.getAttribute("data-animate-id"); + if (id && el instanceof HTMLElement) { + const rect = el.getBoundingClientRect(); + capturedPositionsRef.current.set(id, rect); + } + }); + }, []); + // Use custom hook for sorting (now operates on filtered rows) const { sort, sortedRows, updateSort, computeSortedRowsPreview } = useSortableData({ headers, @@ -580,6 +595,7 @@ const SimpleTableComp = ({ rowGrouping, initialSortColumn, initialSortDirection, + onBeforeSort: captureAllPositions, }); // Flatten sorted rows - this converts nested Row[] to flat TableRow[] @@ -596,19 +612,6 @@ const SimpleTableComp = ({ hasEmptyRenderer: Boolean(emptyStateRenderer), }); - // Also flatten the original aggregated rows for animation baseline positions - const originalFlattenedRows = useFlattenedRows({ - rows: aggregatedRows, - rowGrouping, - rowIdAccessor, - unexpandedRows, - expandAll, - rowStateMap, - hasLoadingRenderer: Boolean(loadingStateRenderer), - hasErrorRenderer: Boolean(errorStateRenderer), - hasEmptyRenderer: Boolean(emptyStateRenderer), - }); - // Create flattened preview functions for animations const computeFlattenedFilteredRowsPreview = useCallback( (filter: FilterCondition) => { @@ -707,14 +710,16 @@ const SimpleTableComp = ({ // Process rows through pagination and virtualization (now operates on flattened rows) const { currentTableRows, - rowsToRender, + currentVisibleRows, + rowsEnteringTheDom, prepareForFilterChange, prepareForSortChange, + cleanupAnimationRows, isAnimating, + animationStartTime, } = useTableRowProcessing({ allowAnimations, flattenedRows, - originalFlattenedRows, currentPage, rowsPerPage, shouldPaginate, @@ -728,6 +733,18 @@ const SimpleTableComp = ({ computeSortedRowsPreview: computeFlattenedSortedRowsPreview, }); + // Cleanup animation rows after animation completes + useEffect(() => { + if (isAnimating && animationStartTime > 0) { + // Animation duration is 200ms, add buffer for safety + const timeoutId = setTimeout(() => { + cleanupAnimationRows(); + }, ANIMATION_CONFIGS.ROW_REORDER.duration + 50); + + return () => clearTimeout(timeoutId); + } + }, [isAnimating, animationStartTime, cleanupAnimationRows]); + // Create a registry for cells to enable direct updates const cellRegistryRef = useRef>(new Map()); @@ -766,14 +783,19 @@ const SimpleTableComp = ({ const onSort = useCallback( (accessor: Accessor) => { // STAGE 1: Prepare animation by adding entering rows before applying sort - prepareForSortChange(accessor); + // Pass captureAllPositions so it can capture leaving rows before updating their positions + prepareForSortChange(accessor, currentVisibleRows, captureAllPositions); // STAGE 2: Apply sort after Stage 1 is rendered (next frame) - setTimeout(() => { - updateSort({ accessor }); - }, 0); + // Note: Position capture happens in updateSort via onBeforeSort callback + // Use double RAF to ensure browser has completed layout before capturing positions + requestAnimationFrame(() => { + requestAnimationFrame(() => { + updateSort({ accessor }); + }); + }); }, - [prepareForSortChange, updateSort] + [prepareForSortChange, updateSort, currentVisibleRows, captureAllPositions] ); const onTableHeaderDragEnd = useCallback((newHeaders: HeaderObject[]) => { @@ -818,7 +840,7 @@ const SimpleTableComp = ({ tableRef, updateFilter, updateSort, - visibleRows: rowsToRender, + visibleRows: currentVisibleRows, }); useExternalFilters({ filters, onFilterChange }); useExternalSort({ sort, onSortChange }); @@ -827,15 +849,22 @@ const SimpleTableComp = ({ const handleApplyFilter = useCallback( (filter: FilterCondition) => { // STAGE 1: Prepare animation by adding entering rows before applying filter - prepareForFilterChange(filter); + // Pass captureAllPositions so it can capture leaving rows before updating their positions + prepareForFilterChange(filter, captureAllPositions); // STAGE 2: Apply filter after Stage 1 is rendered (next frame) - setTimeout(() => { - // Update internal state and call external handler if provided - updateFilter(filter); - }, 0); + // Use double RAF to ensure browser has completed layout before capturing positions + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Capture positions right before filter state change + captureAllPositions(); + + // Update internal state and call external handler if provided + updateFilter(filter); + }); + }); }, - [prepareForFilterChange, updateFilter] + [prepareForFilterChange, updateFilter, captureAllPositions] ); // Check if we should show the empty state (no rows after filtering and not loading) @@ -849,6 +878,7 @@ const SimpleTableComp = ({ areAllRowsSelected, autoExpandColumns, canExpandRowGroup, + capturedPositionsRef, cellRegistry: cellRegistryRef.current, cellUpdateFlash, clearSelection, @@ -965,6 +995,8 @@ const SimpleTableComp = ({
{ + currentVisibleRows.forEach((tableRow, index) => { const rowId = String( getRowId({ row: tableRow.row, @@ -121,7 +122,7 @@ const TableBody = ({ }); return indices; - }, [rowsToRender, rowIdAccessor]); + }, [currentVisibleRows, rowIdAccessor]); // Check if we should load more data const checkForLoadMore = useCallback( @@ -197,7 +198,8 @@ const TableBody = ({ headers, rowHeight, rowIndices, - rowsToRender, + currentVisibleRows, + rowsEnteringTheDom, setHoveredIndex, }; diff --git a/src/components/simple-table/TableCell.tsx b/src/components/simple-table/TableCell.tsx index 9ea6319c3..3af89dee7 100644 --- a/src/components/simple-table/TableCell.tsx +++ b/src/components/simple-table/TableCell.tsx @@ -6,7 +6,7 @@ import useDragHandler from "../../hooks/useDragHandler"; import { DRAG_THROTTLE_LIMIT } from "../../consts/general-consts"; import { getCellId, getCellKey } from "../../utils/cellUtils"; import TableCellProps from "../../types/TableCellProps"; -import { useTableContext } from "../../context/TableContext"; +import { useTableStaticContext } from "../../context/TableContext"; import HeaderObject from "../../types/HeaderObject"; import { formatDate } from "../../utils/formatters"; import { @@ -105,8 +105,14 @@ const TableCell = ({ parentHeader, rowIndex, tableRow, + // Dynamic context values passed as props + expandAll, + isLoading, + rowsWithSelectedCells, + selectedColumns, + unexpandedRows, }: TableCellProps) => { - // Get shared props from context + // Get static context values (won't cause re-renders when dynamic state changes) const { canExpandRowGroup, cellRegistry, @@ -114,7 +120,6 @@ const TableCell = ({ columnBorders, draggedHeaderRef, enableRowSelection, - expandAll, expandIcon, handleMouseDown, handleMouseOver, @@ -122,7 +127,6 @@ const TableCell = ({ headers, hoveredHeaderRef, isCopyFlashing, - isLoading, isRowSelected, isWarningFlashing, onCellEdit, @@ -133,14 +137,11 @@ const TableCell = ({ rowGrouping, rowIdAccessor, setRowStateMap, - rowsWithSelectedCells, - selectedColumns, setUnexpandedRows, tableBodyContainerRef, theme, - unexpandedRows, useOddColumnBackground, - } = useTableContext(); + } = useTableStaticContext(); const { depth, row, rowPath, absoluteRowIndex } = tableRow; @@ -677,8 +678,10 @@ const TableCell = ({ /** * Custom comparison function for React.memo optimization - * Checks if props have actually changed to prevent unnecessary re-renders - * Only re-renders when essential props that affect display have changed + * With split contexts, we only need to check: + * 1. Core cell props (rowIndex, colIndex, etc.) + * 2. Dynamic context props (expandAll, isLoading, etc.) + * Static context values don't trigger re-renders since they're from a separate context */ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): boolean => { // Quick reference checks for props that change frequently @@ -705,6 +708,10 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo ) { return false; } + // CRITICAL: If row data and position are the same, treat tableRow as equal + // even if other properties changed (like absoluteRowIndex, isLastGroupRow, etc.) + // This prevents interrupting ongoing animations when a new sort/filter is applied + // but the row ends up at the same position. } // Header comparison - compare by reference and key properties @@ -736,7 +743,27 @@ const arePropsEqual = (prevProps: TableCellProps, nextProps: TableCellProps): bo return false; } - // If all checks pass, props are equal - skip re-render + // Check dynamic context props (these are the only context values passed as props now) + if (prevProps.isLoading !== nextProps.isLoading || prevProps.expandAll !== nextProps.expandAll) { + return false; + } + + // Check if unexpandedRows Set changed (by reference) + if (prevProps.unexpandedRows !== nextProps.unexpandedRows) { + return false; + } + + // Check if selectedColumns Set changed (by reference) + if (prevProps.selectedColumns !== nextProps.selectedColumns) { + return false; + } + + // Check if rowsWithSelectedCells Set changed (by reference) + if (prevProps.rowsWithSelectedCells !== nextProps.rowsWithSelectedCells) { + return false; + } + + // All checks passed - props are equal, skip re-render return true; }; diff --git a/src/components/simple-table/TableContent.tsx b/src/components/simple-table/TableContent.tsx index 1b4e61fb8..9221b4a24 100644 --- a/src/components/simple-table/TableContent.tsx +++ b/src/components/simple-table/TableContent.tsx @@ -10,6 +10,8 @@ import TableRow from "../../types/TableRow"; // Define props for the frequently changing values not in context interface TableContentLocalProps { + currentVisibleRows: TableRow[]; + rowsEnteringTheDom: TableRow[]; pinnedLeftWidth: number; pinnedRightWidth: number; setScrollTop: (scrollTop: number) => void; @@ -17,10 +19,11 @@ interface TableContentLocalProps { shouldShowEmptyState: boolean; sort: SortColumn | null; tableRows: TableRow[]; - rowsToRender: TableRow[]; } const TableContent = ({ + currentVisibleRows, + rowsEnteringTheDom, pinnedLeftWidth, pinnedRightWidth, setScrollTop, @@ -28,7 +31,6 @@ const TableContent = ({ shouldShowEmptyState, sort, tableRows, - rowsToRender, }: TableContentLocalProps) => { // Get stable props from context const { columnResizing, editColumns, headers, collapsedHeaders, autoExpandColumns } = @@ -79,6 +81,8 @@ const TableContent = ({ }; const tableBodyProps: TableBodyProps = { + currentVisibleRows, + rowsEnteringTheDom, tableRows, mainTemplateColumns, pinnedLeftColumns, @@ -90,7 +94,6 @@ const TableContent = ({ setScrollTop, setScrollDirection, shouldShowEmptyState, - rowsToRender, }; return ( diff --git a/src/components/simple-table/TableSection.tsx b/src/components/simple-table/TableSection.tsx index 37f179225..33707e7af 100644 --- a/src/components/simple-table/TableSection.tsx +++ b/src/components/simple-table/TableSection.tsx @@ -21,11 +21,12 @@ import { useTableContext } from "../../context/TableContext"; interface TableSectionProps { columnIndexStart?: number; // This is to know how many columns there were before this section to see if the columns are odd or even columnIndices: ColumnIndices; + currentVisibleRows: TableRowType[]; + rowsEnteringTheDom: TableRowType[]; headers: HeaderObject[]; pinned?: Pinned; rowHeight: number; rowIndices: RowIndices; - rowsToRender: TableRowType[]; setHoveredIndex: (index: number | null) => void; templateColumns: string; totalHeight: number; @@ -37,6 +38,8 @@ const TableSection = forwardRef( { columnIndexStart, columnIndices, + currentVisibleRows, + rowsEnteringTheDom, headers, pinned, rowHeight, @@ -44,7 +47,6 @@ const TableSection = forwardRef( setHoveredIndex, templateColumns, totalHeight, - rowsToRender, width, }, ref @@ -76,7 +78,43 @@ const TableSection = forwardRef( ...(!pinned && { flexGrow: 1 }), }} > - {rowsToRender.map((tableRow, index) => { + {currentVisibleRows.map((tableRow, index) => { + // Generate unique key - use stateIndicator parentRowId for state rows + const rowId = tableRow.stateIndicator + ? `state-${tableRow.stateIndicator.parentRowId}-${tableRow.position}` + : getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }); + + return ( + + {index !== 0 && ( + + )} + + + ); + })} + {rowsEnteringTheDom.map((tableRow, index) => { // Generate unique key - use stateIndicator parentRowId for state rows const rowId = tableRow.stateIndicator ? `state-${tableRow.stateIndicator.parentRowId}-${tableRow.position}` diff --git a/src/context/TableContext.tsx b/src/context/TableContext.tsx index 89c075ada..db8c3cc9c 100644 --- a/src/context/TableContext.tsx +++ b/src/context/TableContext.tsx @@ -1,150 +1,14 @@ -import { - ReactNode, - RefObject, - MutableRefObject, - createContext, - useContext, - Dispatch, - SetStateAction, -} from "react"; -import { TableFilterState, FilterCondition } from "../types/FilterTypes"; -import TableRow from "../types/TableRow"; -import Cell from "../types/Cell"; -import HeaderObject, { Accessor } from "../types/HeaderObject"; -import OnSortProps from "../types/OnSortProps"; -import Theme from "../types/Theme"; -import CellValue from "../types/CellValue"; -import CellClickProps from "../types/CellClickProps"; -import { RowButton } from "../types/RowButton"; -import { HeaderDropdown } from "../types/HeaderDropdownProps"; -import OnRowGroupExpandProps from "../types/OnRowGroupExpandProps"; -import RowState from "../types/RowState"; -import Row from "../types/Row"; -import { - LoadingStateRenderer, - ErrorStateRenderer, - EmptyStateRenderer, -} from "../types/RowStateRendererProps"; +import { ReactNode, useMemo } from "react"; +import { TableStaticProvider, TableDynamicProvider, TableContextType } from "./TableContexts"; -// Define the interface for cell registry entries -export interface CellRegistryEntry { - updateContent: (newValue: CellValue) => void; -} - -// Define the interface for header cell registry entries -export interface HeaderRegistryEntry { - setEditing: (isEditing: boolean) => void; -} - -interface TableContextType { - // Stable values that don't change frequently - activeHeaderDropdown?: HeaderObject | null; - allowAnimations?: boolean; - areAllRowsSelected?: () => boolean; - autoExpandColumns?: boolean; - canExpandRowGroup?: (row: Row) => boolean; - cellRegistry?: Map; - cellUpdateFlash?: boolean; - clearSelection?: () => void; - collapsedHeaders: Set; - columnBorders: boolean; - columnReordering: boolean; - columnResizing: boolean; - copyHeadersToClipboard: boolean; - draggedHeaderRef: MutableRefObject; - editColumns?: boolean; - enableHeaderEditing?: boolean; - enableRowSelection?: boolean; - expandAll: boolean; - expandIcon?: ReactNode; - filterIcon?: ReactNode; - filters: TableFilterState; - includeHeadersInCSVExport: boolean; - loadingStateRenderer?: LoadingStateRenderer; - errorStateRenderer?: ErrorStateRenderer; - emptyStateRenderer?: EmptyStateRenderer; - forceUpdate: () => void; - rowStateMap: Map; - setRowStateMap: Dispatch>>; - rows: Row[]; - getBorderClass: (cell: Cell) => string; - handleApplyFilter: (filter: FilterCondition) => void; - handleClearAllFilters: () => void; - handleClearFilter: (accessor: Accessor) => void; - handleMouseDown: (cell: Cell) => void; - handleMouseOver: (cell: Cell) => void; - handleRowSelect?: (rowId: string, isSelected: boolean) => void; - handleSelectAll?: (isSelected: boolean) => void; - handleToggleRow?: (rowId: string) => void; - headerCollapseIcon?: ReactNode; - headerContainerRef: RefObject; - headerDropdown?: HeaderDropdown; - headerExpandIcon?: ReactNode; - headerRegistry?: Map; - headers: HeaderObject[]; - hoveredHeaderRef: MutableRefObject; - isAnimating: boolean; - isCopyFlashing: (cell: Cell) => boolean; - isInitialFocusedCell: (cell: Cell) => boolean; - isLoading?: boolean; - isResizing: boolean; - isRowSelected?: (rowId: string) => boolean; - isScrolling: boolean; - isSelected: (cell: Cell) => boolean; - isWarningFlashing: (cell: Cell) => boolean; - mainBodyRef: RefObject; - nextIcon: ReactNode; - onCellEdit?: (props: any) => void; - onCellClick?: (props: CellClickProps) => void; - onColumnOrderChange?: (newHeaders: HeaderObject[]) => void; - onColumnSelect?: (header: HeaderObject) => void; - onHeaderEdit?: (header: HeaderObject, newLabel: string) => void; - onLoadMore?: () => void; - onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise; - onSort: OnSortProps; - onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void; - pinnedLeftRef: RefObject; - pinnedRightRef: RefObject; - prevIcon: ReactNode; - rowButtons?: RowButton[]; - rowGrouping?: Accessor[]; - rowHeight: number; - headerHeight: number; - rowIdAccessor: Accessor; - scrollbarWidth: number; - selectColumns?: (columnIndices: number[], isShiftKey?: boolean) => void; - selectableColumns: boolean; - selectedColumns: Set; - columnsWithSelectedCells: Set; - rowsWithSelectedCells: Set; - selectedRows?: Set; - selectedRowCount?: number; - selectedRowsData?: any[]; - setActiveHeaderDropdown?: Dispatch>; - setCollapsedHeaders: Dispatch>>; - setHeaders: Dispatch>; - setInitialFocusedCell: Dispatch>; - setIsResizing: Dispatch>; - setIsScrolling: Dispatch>; - setSelectedCells: Dispatch>>; - setSelectedColumns: Dispatch>>; - setSelectedRows?: Dispatch>>; - setUnexpandedRows: Dispatch>>; - shouldPaginate: boolean; - sortDownIcon: ReactNode; - sortUpIcon: ReactNode; - tableBodyContainerRef: RefObject; - tableEmptyStateRenderer?: ReactNode; - tableRows: TableRow[]; - theme: Theme; - unexpandedRows: Set; - useHoverRowBackground: boolean; - useOddColumnBackground: boolean; - useOddEvenRowBackground: boolean; -} - -export const TableContext = createContext(undefined); +// Re-export everything from the new split contexts +export * from "./TableContexts"; +export type { TableContextType } from "./TableContexts"; +/** + * Combined TableProvider that wraps both Static and Dynamic contexts + * This maintains backward compatibility while enabling the split context optimization + */ export const TableProvider = ({ children, value, @@ -152,13 +16,228 @@ export const TableProvider = ({ children: ReactNode; value: TableContextType; }) => { - return {children}; -}; + // Split the value into static and dynamic parts + // Static values are memoized and only change when configuration changes + const staticValue = useMemo( + () => ({ + // Configuration + allowAnimations: value.allowAnimations, + autoExpandColumns: value.autoExpandColumns, + canExpandRowGroup: value.canExpandRowGroup, + cellUpdateFlash: value.cellUpdateFlash, + columnBorders: value.columnBorders, + columnReordering: value.columnReordering, + columnResizing: value.columnResizing, + copyHeadersToClipboard: value.copyHeadersToClipboard, + editColumns: value.editColumns, + enableHeaderEditing: value.enableHeaderEditing, + enableRowSelection: value.enableRowSelection, + includeHeadersInCSVExport: value.includeHeadersInCSVExport, + rowHeight: value.rowHeight, + headerHeight: value.headerHeight, + rowIdAccessor: value.rowIdAccessor, + scrollbarWidth: value.scrollbarWidth, + selectableColumns: value.selectableColumns, + shouldPaginate: value.shouldPaginate, + theme: value.theme, + useHoverRowBackground: value.useHoverRowBackground, + useOddColumnBackground: value.useOddColumnBackground, + useOddEvenRowBackground: value.useOddEvenRowBackground, + + // Refs (always stable) + capturedPositionsRef: value.capturedPositionsRef, + draggedHeaderRef: value.draggedHeaderRef, + hoveredHeaderRef: value.hoveredHeaderRef, + headerContainerRef: value.headerContainerRef, + mainBodyRef: value.mainBodyRef, + pinnedLeftRef: value.pinnedLeftRef, + pinnedRightRef: value.pinnedRightRef, + tableBodyContainerRef: value.tableBodyContainerRef, + + // Registries + cellRegistry: value.cellRegistry, + headerRegistry: value.headerRegistry, + + // Callbacks (should be memoized in parent) + forceUpdate: value.forceUpdate, + getBorderClass: value.getBorderClass, + handleApplyFilter: value.handleApplyFilter, + handleClearAllFilters: value.handleClearAllFilters, + handleClearFilter: value.handleClearFilter, + handleMouseDown: value.handleMouseDown, + handleMouseOver: value.handleMouseOver, + handleRowSelect: value.handleRowSelect, + handleSelectAll: value.handleSelectAll, + handleToggleRow: value.handleToggleRow, + isCopyFlashing: value.isCopyFlashing, + isInitialFocusedCell: value.isInitialFocusedCell, + isRowSelected: value.isRowSelected, + isSelected: value.isSelected, + isWarningFlashing: value.isWarningFlashing, + onCellEdit: value.onCellEdit, + onCellClick: value.onCellClick, + onColumnOrderChange: value.onColumnOrderChange, + onColumnSelect: value.onColumnSelect, + onHeaderEdit: value.onHeaderEdit, + onLoadMore: value.onLoadMore, + onRowGroupExpand: value.onRowGroupExpand, + onSort: value.onSort, + onTableHeaderDragEnd: value.onTableHeaderDragEnd, + selectColumns: value.selectColumns, + clearSelection: value.clearSelection, + areAllRowsSelected: value.areAllRowsSelected, + + // State setters (stable references) + setActiveHeaderDropdown: value.setActiveHeaderDropdown, + setCollapsedHeaders: value.setCollapsedHeaders, + setHeaders: value.setHeaders, + setInitialFocusedCell: value.setInitialFocusedCell, + setIsResizing: value.setIsResizing, + setIsScrolling: value.setIsScrolling, + setSelectedCells: value.setSelectedCells, + setSelectedColumns: value.setSelectedColumns, + setSelectedRows: value.setSelectedRows, + setUnexpandedRows: value.setUnexpandedRows, + setRowStateMap: value.setRowStateMap, + + // Arrays/Objects (should be memoized in parent) + headers: value.headers, + rowButtons: value.rowButtons, + rowGrouping: value.rowGrouping, + rows: value.rows, + + // Icons (stable) + expandIcon: value.expandIcon, + filterIcon: value.filterIcon, + headerCollapseIcon: value.headerCollapseIcon, + headerExpandIcon: value.headerExpandIcon, + nextIcon: value.nextIcon, + prevIcon: value.prevIcon, + sortDownIcon: value.sortDownIcon, + sortUpIcon: value.sortUpIcon, + + // Renderers (stable) + loadingStateRenderer: value.loadingStateRenderer, + errorStateRenderer: value.errorStateRenderer, + emptyStateRenderer: value.emptyStateRenderer, + tableEmptyStateRenderer: value.tableEmptyStateRenderer, + headerDropdown: value.headerDropdown, + }), + [ + value.allowAnimations, + value.autoExpandColumns, + value.canExpandRowGroup, + value.cellUpdateFlash, + value.columnBorders, + value.columnReordering, + value.columnResizing, + value.copyHeadersToClipboard, + value.editColumns, + value.enableHeaderEditing, + value.enableRowSelection, + value.includeHeadersInCSVExport, + value.rowHeight, + value.headerHeight, + value.rowIdAccessor, + value.scrollbarWidth, + value.selectableColumns, + value.shouldPaginate, + value.theme, + value.useHoverRowBackground, + value.useOddColumnBackground, + value.useOddEvenRowBackground, + value.capturedPositionsRef, + value.draggedHeaderRef, + value.hoveredHeaderRef, + value.headerContainerRef, + value.mainBodyRef, + value.pinnedLeftRef, + value.pinnedRightRef, + value.tableBodyContainerRef, + value.cellRegistry, + value.headerRegistry, + value.forceUpdate, + value.getBorderClass, + value.handleApplyFilter, + value.handleClearAllFilters, + value.handleClearFilter, + value.handleMouseDown, + value.handleMouseOver, + value.handleRowSelect, + value.handleSelectAll, + value.handleToggleRow, + value.isCopyFlashing, + value.isInitialFocusedCell, + value.isRowSelected, + value.isSelected, + value.isWarningFlashing, + value.onCellEdit, + value.onCellClick, + value.onColumnOrderChange, + value.onColumnSelect, + value.onHeaderEdit, + value.onLoadMore, + value.onRowGroupExpand, + value.onSort, + value.onTableHeaderDragEnd, + value.selectColumns, + value.clearSelection, + value.areAllRowsSelected, + value.setActiveHeaderDropdown, + value.setCollapsedHeaders, + value.setHeaders, + value.setInitialFocusedCell, + value.setIsResizing, + value.setIsScrolling, + value.setSelectedCells, + value.setSelectedColumns, + value.setSelectedRows, + value.setUnexpandedRows, + value.setRowStateMap, + value.headers, + value.rowButtons, + value.rowGrouping, + value.rows, + value.expandIcon, + value.filterIcon, + value.headerCollapseIcon, + value.headerExpandIcon, + value.nextIcon, + value.prevIcon, + value.sortDownIcon, + value.sortUpIcon, + value.loadingStateRenderer, + value.errorStateRenderer, + value.emptyStateRenderer, + value.tableEmptyStateRenderer, + value.headerDropdown, + ] + ); + + // Dynamic values change frequently - don't memoize + const dynamicValue = { + activeHeaderDropdown: value.activeHeaderDropdown, + collapsedHeaders: value.collapsedHeaders, + expandAll: value.expandAll, + filters: value.filters, + isAnimating: value.isAnimating, + isLoading: value.isLoading, + isResizing: value.isResizing, + isScrolling: value.isScrolling, + rowStateMap: value.rowStateMap, + selectedColumns: value.selectedColumns, + columnsWithSelectedCells: value.columnsWithSelectedCells, + rowsWithSelectedCells: value.rowsWithSelectedCells, + selectedRows: value.selectedRows, + selectedRowCount: value.selectedRowCount, + selectedRowsData: value.selectedRowsData, + tableRows: value.tableRows, + unexpandedRows: value.unexpandedRows, + }; -export const useTableContext = () => { - const context = useContext(TableContext); - if (context === undefined) { - throw new Error("useTableContext must be used within a TableProvider"); - } - return context; + return ( + + {children} + + ); }; diff --git a/src/context/TableContexts.tsx b/src/context/TableContexts.tsx new file mode 100644 index 000000000..8bcf4e049 --- /dev/null +++ b/src/context/TableContexts.tsx @@ -0,0 +1,220 @@ +import { + ReactNode, + RefObject, + MutableRefObject, + createContext, + useContext, + Dispatch, + SetStateAction, +} from "react"; +import { TableFilterState, FilterCondition } from "../types/FilterTypes"; +import TableRow from "../types/TableRow"; +import Cell from "../types/Cell"; +import HeaderObject, { Accessor } from "../types/HeaderObject"; +import OnSortProps from "../types/OnSortProps"; +import Theme from "../types/Theme"; +import CellValue from "../types/CellValue"; +import CellClickProps from "../types/CellClickProps"; +import { RowButton } from "../types/RowButton"; +import { HeaderDropdown } from "../types/HeaderDropdownProps"; +import OnRowGroupExpandProps from "../types/OnRowGroupExpandProps"; +import RowState from "../types/RowState"; +import Row from "../types/Row"; +import { + LoadingStateRenderer, + ErrorStateRenderer, + EmptyStateRenderer, +} from "../types/RowStateRendererProps"; + +// Define the interface for cell registry entries +export interface CellRegistryEntry { + updateContent: (newValue: CellValue) => void; +} + +// Define the interface for header cell registry entries +export interface HeaderRegistryEntry { + setEditing: (isEditing: boolean) => void; +} + +/** + * Static Context - Contains stable values that rarely/never change + * Components using this context won't re-render when dynamic state changes + */ +interface TableStaticContextType { + // Configuration (set once, rarely changes) + allowAnimations?: boolean; + autoExpandColumns?: boolean; + canExpandRowGroup?: (row: Row) => boolean; + cellUpdateFlash?: boolean; + columnBorders: boolean; + columnReordering: boolean; + columnResizing: boolean; + copyHeadersToClipboard: boolean; + editColumns?: boolean; + enableHeaderEditing?: boolean; + enableRowSelection?: boolean; + includeHeadersInCSVExport: boolean; + rowHeight: number; + headerHeight: number; + rowIdAccessor: Accessor; + scrollbarWidth: number; + selectableColumns: boolean; + shouldPaginate: boolean; + theme: Theme; + useHoverRowBackground: boolean; + useOddColumnBackground: boolean; + useOddEvenRowBackground: boolean; + + // Refs (stable references) + capturedPositionsRef: MutableRefObject>; + draggedHeaderRef: MutableRefObject; + hoveredHeaderRef: MutableRefObject; + headerContainerRef: RefObject; + mainBodyRef: RefObject; + pinnedLeftRef: RefObject; + pinnedRightRef: RefObject; + tableBodyContainerRef: RefObject; + + // Registries (stable references) + cellRegistry?: Map; + headerRegistry?: Map; + + // Stable callback functions (created once with useCallback) + forceUpdate: () => void; + getBorderClass: (cell: Cell) => string; + handleApplyFilter: (filter: FilterCondition) => void; + handleClearAllFilters: () => void; + handleClearFilter: (accessor: Accessor) => void; + handleMouseDown: (cell: Cell) => void; + handleMouseOver: (cell: Cell) => void; + handleRowSelect?: (rowId: string, isSelected: boolean) => void; + handleSelectAll?: (isSelected: boolean) => void; + handleToggleRow?: (rowId: string) => void; + isCopyFlashing: (cell: Cell) => boolean; + isInitialFocusedCell: (cell: Cell) => boolean; + isRowSelected?: (rowId: string) => boolean; + isSelected: (cell: Cell) => boolean; + isWarningFlashing: (cell: Cell) => boolean; + onCellEdit?: (props: any) => void; + onCellClick?: (props: CellClickProps) => void; + onColumnOrderChange?: (newHeaders: HeaderObject[]) => void; + onColumnSelect?: (header: HeaderObject) => void; + onHeaderEdit?: (header: HeaderObject, newLabel: string) => void; + onLoadMore?: () => void; + onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise; + onSort: OnSortProps; + onTableHeaderDragEnd: (newHeaders: HeaderObject[]) => void; + selectColumns?: (columnIndices: number[], isShiftKey?: boolean) => void; + clearSelection?: () => void; + areAllRowsSelected?: () => boolean; + + // State setters (stable references) + setActiveHeaderDropdown?: Dispatch>; + setCollapsedHeaders: Dispatch>>; + setHeaders: Dispatch>; + setInitialFocusedCell: Dispatch>; + setIsResizing: Dispatch>; + setIsScrolling: Dispatch>; + setSelectedCells: Dispatch>>; + setSelectedColumns: Dispatch>>; + setSelectedRows?: Dispatch>>; + setUnexpandedRows: Dispatch>>; + setRowStateMap: Dispatch>>; + + // Arrays/Objects that should be stable (memoized) + headers: HeaderObject[]; + rowButtons?: RowButton[]; + rowGrouping?: Accessor[]; + rows: Row[]; + + // Icons (stable ReactNodes) + expandIcon?: ReactNode; + filterIcon?: ReactNode; + headerCollapseIcon?: ReactNode; + headerExpandIcon?: ReactNode; + nextIcon: ReactNode; + prevIcon: ReactNode; + sortDownIcon: ReactNode; + sortUpIcon: ReactNode; + + // Renderers (stable functions/components) + loadingStateRenderer?: LoadingStateRenderer; + errorStateRenderer?: ErrorStateRenderer; + emptyStateRenderer?: EmptyStateRenderer; + tableEmptyStateRenderer?: ReactNode; + headerDropdown?: HeaderDropdown; +} + +/** + * Dynamic Context - Contains values that change frequently + * Only components that need these values should subscribe to this context + */ +interface TableDynamicContextType { + // Frequently changing state + activeHeaderDropdown?: HeaderObject | null; + collapsedHeaders: Set; + expandAll: boolean; + filters: TableFilterState; + isAnimating: boolean; + isLoading?: boolean; + isResizing: boolean; + isScrolling: boolean; + rowStateMap: Map; + selectedColumns: Set; + columnsWithSelectedCells: Set; + rowsWithSelectedCells: Set; + selectedRows?: Set; + selectedRowCount?: number; + selectedRowsData?: any[]; + tableRows: TableRow[]; + unexpandedRows: Set; +} + +export const TableStaticContext = createContext(undefined); +export const TableDynamicContext = createContext(undefined); + +export const TableStaticProvider = ({ + children, + value, +}: { + children: ReactNode; + value: TableStaticContextType; +}) => { + return {children}; +}; + +export const TableDynamicProvider = ({ + children, + value, +}: { + children: ReactNode; + value: TableDynamicContextType; +}) => { + return {children}; +}; + +export const useTableStaticContext = () => { + const context = useContext(TableStaticContext); + if (context === undefined) { + throw new Error("useTableStaticContext must be used within a TableStaticProvider"); + } + return context; +}; + +export const useTableDynamicContext = () => { + const context = useContext(TableDynamicContext); + if (context === undefined) { + throw new Error("useTableDynamicContext must be used within a TableDynamicProvider"); + } + return context; +}; + +// Keep the original combined context and hook for backward compatibility +// This allows gradual migration of components +export interface TableContextType extends TableStaticContextType, TableDynamicContextType {} + +export const useTableContext = (): TableContextType => { + const staticContext = useTableStaticContext(); + const dynamicContext = useTableDynamicContext(); + return { ...staticContext, ...dynamicContext }; +}; diff --git a/src/hooks/useSortableData.ts b/src/hooks/useSortableData.ts index de35648fd..e9680ec2b 100644 --- a/src/hooks/useSortableData.ts +++ b/src/hooks/useSortableData.ts @@ -50,6 +50,7 @@ const useSortableData = ({ rowGrouping, initialSortColumn, initialSortDirection, + onBeforeSort, }: { headers: HeaderObject[]; tableRows: Row[]; @@ -58,6 +59,7 @@ const useSortableData = ({ rowGrouping?: string[]; initialSortColumn?: string; initialSortDirection?: SortDirection; + onBeforeSort?: () => void; }) => { // Initialize sort state with initial values if provided const getInitialSort = useCallback((): SortColumn | null => { @@ -202,10 +204,13 @@ const useSortableData = ({ } // If currently desc, clear the sort (newSortColumn stays null) + // CRITICAL: Capture positions right before state change (react-flip-move pattern) + onBeforeSort?.(); + setSort(newSortColumn); onSortChange?.(newSortColumn); }, - [sort, headers, onSortChange] + [sort, headers, onSortChange, onBeforeSort] ); // Function to preview what rows would be after applying a sort diff --git a/src/hooks/useTableRowProcessing.ts b/src/hooks/useTableRowProcessing.ts index 46a5a7e19..f38fc3b78 100644 --- a/src/hooks/useTableRowProcessing.ts +++ b/src/hooks/useTableRowProcessing.ts @@ -1,8 +1,7 @@ -import { useMemo, useState, useLayoutEffect, useCallback, useRef } from "react"; +import { useMemo, useState, useCallback } from "react"; import { calculateBufferRowCount } from "../consts/general-consts"; import { getVisibleRows } from "../utils/infiniteScrollUtils"; import { getRowId } from "../utils/rowUtils"; -import { ANIMATION_CONFIGS } from "../components/animate/animation-utils"; import { Accessor } from "../types/HeaderObject"; import { FilterCondition } from "../types/FilterTypes"; import TableRow from "../types/TableRow"; @@ -11,8 +10,6 @@ interface UseTableRowProcessingProps { allowAnimations: boolean; /** Already flattened rows from useFlattenedRows */ flattenedRows: TableRow[]; - /** Original flattened rows for establishing baseline positions */ - originalFlattenedRows: TableRow[]; currentPage: number; rowsPerPage: number; shouldPaginate: boolean; @@ -30,7 +27,6 @@ interface UseTableRowProcessingProps { const useTableRowProcessing = ({ allowAnimations, flattenedRows, - originalFlattenedRows, currentPage, rowsPerPage, shouldPaginate, @@ -44,22 +40,20 @@ const useTableRowProcessing = ({ computeSortedRowsPreview, }: UseTableRowProcessingProps) => { const [isAnimating, setIsAnimating] = useState(false); - const [extendedRows, setExtendedRows] = useState([]); - const previousTableRowsRef = useRef([]); // Track ALL processed rows, not just visible - const previousVisibleRowsRef = useRef([]); // Track only visible rows for animation + const [animationStartTime, setAnimationStartTime] = useState(0); + const [rowsEnteringTheDom, setRowsEnteringTheDom] = useState([]); + const [rowsLeavingTheDom, setRowsLeavingTheDom] = useState([]); // Calculate buffer row count based on actual row height // This ensures consistent ~800px overscan regardless of row size const bufferRowCount = useMemo(() => calculateBufferRowCount(rowHeight), [rowHeight]); - // Track original positions of all rows (before any sort/filter applied) - const originalPositionsRef = useRef>(new Map()); - - // Capture values when animation starts to avoid dependency issues in timeout effect - const animationCaptureRef = useRef<{ - tableRows: TableRow[]; - visibleRows: TableRow[]; - } | null>(null); + // Cleanup function to reset animation states + const cleanupAnimationRows = useCallback(() => { + setRowsEnteringTheDom([]); + setRowsLeavingTheDom([]); + setIsAnimating(false); + }, []); // Apply pagination to already-flattened rows and recalculate positions const applyPagination = useCallback( @@ -83,26 +77,6 @@ const useTableRowProcessing = ({ [currentPage, rowsPerPage, serverSidePagination, shouldPaginate] ); - // Establish original positions from unfiltered/unsorted data - useMemo(() => { - // Only set original positions once when component first loads - if (originalPositionsRef.current.size === 0 && originalFlattenedRows.length > 0) { - const newOriginalPositions = new Map(); - originalFlattenedRows.forEach((tableRow, index) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - newOriginalPositions.set(id, index); - }); - - originalPositionsRef.current = newOriginalPositions; - } - }, [originalFlattenedRows, rowIdAccessor]); - // Current table rows (paginated for display) // Now pagination happens on FLATTENED rows, so rowsPerPage correctly counts all visible rows const currentTableRows = useMemo(() => { @@ -126,245 +100,48 @@ const useTableRowProcessing = ({ }); }, [currentTableRows, contentHeight, rowHeight, scrollTop, scrollDirection, bufferRowCount]); - // Categorize rows based on ID changes - const categorizeRows = useCallback( - (previousRows: TableRow[], currentRows: TableRow[]) => { - const previousIds = new Set( - previousRows.map((tableRow) => - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ) - ) - ); - const currentIds = new Set( - currentRows.map((tableRow) => - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ) - ) - ); - - const staying = currentRows.filter((tableRow) => { - const id = String( + // Combine target visible rows with leaving rows for rendering + const visibleRowsWithLeaving = useMemo(() => { + // Create a set of IDs from rowsLeavingTheDom + const leavingRowIds = new Set( + rowsLeavingTheDom.map((tableRow) => + String( getRowId({ row: tableRow.row, rowIdAccessor, rowPath: tableRow.rowPath, }) - ); - return previousIds.has(id); - }); - - const entering = currentRows.filter((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - return !previousIds.has(id); - }); - - const leaving = previousRows.filter((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - return !currentIds.has(id); - }); - - return { staying, entering, leaving }; - }, - [rowIdAccessor] - ); - - // Check if there are actual row changes (comparing all rows, not just visible) - const hasRowChanges = useMemo(() => { - if (previousTableRowsRef.current.length === 0) { - return false; - } - - const currentIds = currentTableRows.map((tableRow) => - String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) + ) ) ); - const previousIds = previousTableRowsRef.current.map((tableRow) => - String( + + // Filter out any rows from targetVisibleRows that are already in rowsLeavingTheDom + const uniqueTargetRows = targetVisibleRows.filter((tableRow) => { + const id = String( getRowId({ row: tableRow.row, rowIdAccessor, rowPath: tableRow.rowPath, }) - ) - ); - - const hasChanges = - currentIds.length !== previousIds.length || - !currentIds.every((id, index) => id === previousIds[index]); - - return hasChanges; - }, [currentTableRows, rowIdAccessor]); - - // Animation effect - useLayoutEffect(() => { - // Don't re-run effect while animation is in progress - if (isAnimating) { - return; - } - - // Helper to clear extended rows only if needed (avoid unnecessary state updates - // that would cause infinite re-renders) - const clearExtendedRowsIfNeeded = () => { - if (extendedRows.length > 0) { - setExtendedRows([]); - } - }; - - // Always sync when not animating - if (!allowAnimations || shouldPaginate) { - clearExtendedRowsIfNeeded(); - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // Initialize on first render - if (previousTableRowsRef.current.length === 0) { - clearExtendedRowsIfNeeded(); - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // Check if rows actually changed - this detects STAGE 2 (after sort/filter applied) - if (!hasRowChanges) { - clearExtendedRowsIfNeeded(); - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // STAGE 2: Rows have new positions, trigger animation - // extendedRows already contains current + entering rows (from STAGE 1) - // Now the positions will update automatically when the component re-renders - - // Capture current values before starting animation - animationCaptureRef.current = { - tableRows: currentTableRows, - visibleRows: targetVisibleRows, - }; - - // Start animation - setIsAnimating(true); - }, [ - allowAnimations, - currentTableRows, - extendedRows.length, - hasRowChanges, - isAnimating, - shouldPaginate, - targetVisibleRows, - ]); - - // Separate effect to handle animation timeout - only runs when we have extended rows to animate - useLayoutEffect(() => { - if (isAnimating && animationCaptureRef.current && extendedRows.length > 0) { - // STAGE 3: After animation completes, remove leaving rows - const timeout = setTimeout(() => { - const captured = animationCaptureRef.current!; - setIsAnimating(false); - setExtendedRows([]); // Clear extended rows to use normal virtualization - previousTableRowsRef.current = captured.tableRows; - previousVisibleRowsRef.current = captured.visibleRows; - animationCaptureRef.current = null; // Clean up - }, ANIMATION_CONFIGS.ROW_REORDER.duration + 100); - - return () => clearTimeout(timeout); - } - }, [isAnimating, extendedRows.length]); // Depend on isAnimating AND extendedRows length - - // Final rows to render - handles 3-stage animation - const rowsToRender = useMemo(() => { - // If animations are disabled, always use normal virtualization - if (!allowAnimations || shouldPaginate) { - return targetVisibleRows; - } - - // If we have extended rows (from STAGE 1), we need to dynamically update their positions - // to reflect the current sort/filter state (STAGE 2) - if (extendedRows.length > 0) { - // Create a map of ALL positions from currentTableRows (not just visible ones) - // This ensures we have positions for existing rows AND entering rows - const positionMap = new Map(); - const displayPositionMap = new Map(); - currentTableRows.forEach((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - positionMap.set(id, tableRow.position); - displayPositionMap.set(id, tableRow.displayPosition); - }); - - // Update ALL rows in extendedRows with their new positions - const updatedExtendedRows = extendedRows.map((tableRow) => { - const id = String( - getRowId({ - row: tableRow.row, - rowIdAccessor, - rowPath: tableRow.rowPath, - }) - ); - const newPosition = positionMap.get(id); - const newDisplayPosition = displayPositionMap.get(id); - - // If this row exists in the new sorted state, use its new position - // Otherwise keep the original position (for leaving rows that are no longer in the sorted data) - if (newPosition !== undefined && newDisplayPosition !== undefined) { - return { ...tableRow, position: newPosition, displayPosition: newDisplayPosition }; - } - return tableRow; - }); - - return updatedExtendedRows; - } + ); + return !leavingRowIds.has(id); + }); - // Default: use normal visible rows (STAGE 3 after animation completes) - return targetVisibleRows; - }, [ - targetVisibleRows, - extendedRows, - currentTableRows, - allowAnimations, - shouldPaginate, - rowIdAccessor, - ]); + // Combine unique target rows with leaving rows (leaving rows take precedence) + return [...uniqueTargetRows, ...rowsLeavingTheDom]; + }, [targetVisibleRows, rowsLeavingTheDom, rowIdAccessor]); // Animation handlers for filter/sort changes const prepareForFilterChange = useCallback( - (filter: FilterCondition) => { + (filter: FilterCondition, capturePositions?: () => void) => { if (!allowAnimations || shouldPaginate || contentHeight === undefined) return; + // CRITICAL: Capture positions of existing leaving rows BEFORE updating them + // This prevents teleporting when their positions change + if (capturePositions) { + capturePositions(); + } + // Calculate what rows would be after filter (already flattened from preview function) const newFlattenedRows = computeFilteredRowsPreview(filter); const newPaginatedRows = applyPagination(newFlattenedRows); @@ -377,21 +154,18 @@ const useTableRowProcessing = ({ scrollDirection, }); - // CRITICAL: Compare VISIBLE rows (before filter) vs what WILL BE visible (after filter) - // This identifies rows that are entering the visible area - const { entering: visibleEntering } = categorizeRows(targetVisibleRows, newVisibleRows); - - // Find these entering rows in the CURRENT table state (before filter) to get original positions - const enteringFromCurrentState = visibleEntering - .map((enteringRow) => { + // Find all rows that WILL BE visible after the filter, but with their CURRENT positions (before filter) + // This gives us the starting point for animation + const rowsInCurrentPosition = newVisibleRows + .map((newVisibleRow) => { const id = String( getRowId({ - row: enteringRow.row, + row: newVisibleRow.row, rowIdAccessor, - rowPath: enteringRow.rowPath, + rowPath: newVisibleRow.rowPath, }) ); - // Find this row in the current table state to get its original position + // Find this row in the CURRENT table state (before filter) to get its current position const currentStateRow = currentTableRows.find( (currentRow) => String( @@ -402,13 +176,150 @@ const useTableRowProcessing = ({ }) ) === id ); - return currentStateRow || enteringRow; // Fallback to enteringRow if not found + return currentStateRow || newVisibleRow; // Fallback to newVisibleRow if not found in current state }) .filter(Boolean) as TableRow[]; - if (enteringFromCurrentState.length > 0) { - setExtendedRows([...targetVisibleRows, ...enteringFromCurrentState]); - } + setIsAnimating(true); + setAnimationStartTime(Date.now()); + + // Add unique rows to rowsEnteringTheDom (don't add duplicates from targetVisibleRows or existing rowsEnteringTheDom) + setRowsEnteringTheDom((existingRows) => { + // Create set of IDs already in targetVisibleRows + const targetVisibleIds = new Set( + targetVisibleRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Create set of IDs already in existingRows + const existingRowIds = new Set( + existingRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Filter to only include rows that aren't already in targetVisibleRows or existingRows + const uniqueNewRows = rowsInCurrentPosition.filter((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + return !targetVisibleIds.has(id) && !existingRowIds.has(id); + }); + + // Add unique rows to existing rows + return [...existingRows, ...uniqueNewRows]; + }); + + // Track rows that are leaving the DOM (currently visible but won't exist in new dataset after filter) + setRowsLeavingTheDom((existingRows) => { + // CRITICAL: Create set of IDs that will exist in the FULL paginated dataset (not just viewport) + // A row is only "leaving" if it's being filtered out entirely, not just scrolling out of view + const newPaginatedIds = new Set( + newPaginatedRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Create map of existing leaving rows for quick lookup + const existingRowsMap = new Map( + existingRows.map((tableRow) => [ + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ), + tableRow, + ]) + ); + + // Find rows from targetVisibleRows that won't exist in the new paginated dataset (filtered out) + const candidateLeavingRows = targetVisibleRows.filter((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + // Only consider it "leaving" if it's not in the new paginated dataset at all + return !newPaginatedIds.has(id); + }); + + // Separate into rows that need position updates vs truly new leaving rows + const rowsToUpdate: TableRow[] = []; + const newLeavingRows: TableRow[] = []; + + candidateLeavingRows.forEach((leavingRow) => { + const id = String( + getRowId({ + row: leavingRow.row, + rowIdAccessor, + rowPath: leavingRow.rowPath, + }) + ); + // This row is being filtered out, so it won't have a position in newPaginatedRows + // Keep its current position for animation purposes + const rowWithNewPosition = leavingRow; + + if (existingRowsMap.has(id)) { + // Row is already leaving, keep it + rowsToUpdate.push(rowWithNewPosition); + } else { + // Row is newly leaving + newLeavingRows.push(rowWithNewPosition); + } + }); + + // Update existing rows with new positions, keep rows not in update list unchanged + const updatedExistingRows = existingRows.map((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + const updatedRow = rowsToUpdate.find( + (r) => + String( + getRowId({ + row: r.row, + rowIdAccessor, + rowPath: r.rowPath, + }) + ) === id + ); + return updatedRow || tableRow; + }); + + // Combine updated existing rows with new leaving rows + return [...updatedExistingRows, ...newLeavingRows]; + }); }, [ allowAnimations, @@ -419,7 +330,6 @@ const useTableRowProcessing = ({ rowHeight, scrollTop, scrollDirection, - categorizeRows, currentTableRows, targetVisibleRows, rowIdAccessor, @@ -428,8 +338,16 @@ const useTableRowProcessing = ({ ); const prepareForSortChange = useCallback( - (accessor: Accessor) => { - if (!allowAnimations || shouldPaginate || contentHeight === undefined) return; + (accessor: Accessor, targetVisibleRows: TableRow[], capturePositions?: () => void) => { + if (!allowAnimations || shouldPaginate || contentHeight === undefined) { + return; + } + + // CRITICAL: Capture positions of existing leaving rows BEFORE updating them + // This prevents teleporting when their positions change + if (capturePositions) { + capturePositions(); + } // Calculate what rows would be after sort (already flattened from preview function) const newFlattenedRows = computeSortedRowsPreview(accessor); @@ -443,21 +361,18 @@ const useTableRowProcessing = ({ scrollDirection, }); - // CRITICAL: Compare VISIBLE rows (before sort) vs what WILL BE visible (after sort) - // This identifies rows that are entering the visible area - const { entering: visibleEntering } = categorizeRows(targetVisibleRows, newVisibleRows); - - // Find these entering rows in the CURRENT table state (before sort) to get original positions - const enteringFromCurrentState = visibleEntering - .map((enteringRow) => { + // Find all rows that WILL BE visible after the sort, but with their CURRENT positions (before sort) + // This gives us the starting point for animation + const rowsInCurrentPosition = newVisibleRows + .map((newVisibleRow) => { const id = String( getRowId({ - row: enteringRow.row, + row: newVisibleRow.row, rowIdAccessor, - rowPath: enteringRow.rowPath, + rowPath: newVisibleRow.rowPath, }) ); - // Find this row in the current table state to get its original position + // Find this row in the CURRENT table state (before sort) to get its current position const currentStateRow = currentTableRows.find( (currentRow) => String( @@ -468,13 +383,62 @@ const useTableRowProcessing = ({ }) ) === id ); - return currentStateRow || enteringRow; // Fallback to enteringRow if not found + + return currentStateRow || newVisibleRow; // Fallback to newVisibleRow if not found in current state }) .filter(Boolean) as TableRow[]; - if (enteringFromCurrentState.length > 0) { - setExtendedRows([...targetVisibleRows, ...enteringFromCurrentState]); - } + setIsAnimating(true); + setAnimationStartTime(Date.now()); + + // Add unique rows to rowsEnteringTheDom (don't add duplicates from targetVisibleRows or existing rowsEnteringTheDom) + setRowsEnteringTheDom((existingRows) => { + // Create set of IDs already in targetVisibleRows + const targetVisibleIds = new Set( + targetVisibleRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Create set of IDs already in existingRows + const existingRowIds = new Set( + existingRows.map((tableRow) => + String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ) + ) + ); + + // Filter to only include rows that aren't already in targetVisibleRows or existingRows + const uniqueNewRows = rowsInCurrentPosition.filter((tableRow) => { + const id = String( + getRowId({ + row: tableRow.row, + rowIdAccessor, + rowPath: tableRow.rowPath, + }) + ); + + return !targetVisibleIds.has(id) && !existingRowIds.has(id); + }); + + // Add unique rows to existing rows + return [...existingRows, ...uniqueNewRows]; + }); + + // For sorting, rows don't actually "leave" - they just reorder + // So we don't add any rows to rowsLeavingTheDom for sort operations + // Rows will animate in place as they reorder }, [ allowAnimations, @@ -485,9 +449,7 @@ const useTableRowProcessing = ({ rowHeight, scrollTop, scrollDirection, - categorizeRows, currentTableRows, - targetVisibleRows, rowIdAccessor, bufferRowCount, ] @@ -495,11 +457,13 @@ const useTableRowProcessing = ({ return { currentTableRows, - currentVisibleRows: targetVisibleRows, + currentVisibleRows: visibleRowsWithLeaving, isAnimating, + animationStartTime, prepareForFilterChange, prepareForSortChange, - rowsToRender, + cleanupAnimationRows, + rowsEnteringTheDom, }; }; diff --git a/src/stories/examples/BasicExample.tsx b/src/stories/examples/BasicExample.tsx index 4a8b1bde8..a67203267 100644 --- a/src/stories/examples/BasicExample.tsx +++ b/src/stories/examples/BasicExample.tsx @@ -4,6 +4,7 @@ import { UniversalTableProps } from "./StoryWrapper"; // Default args specific to BasicExample - exported for reuse in stories and tests export const basicExampleDefaults = { + allowAnimations: true, columnResizing: true, editColumns: true, selectableCells: true, @@ -28,7 +29,7 @@ const BasicExampleComponent = (props: UniversalTableProps) => { // Define headers const headers: HeaderObject[] = [ - { accessor: "id", label: "ID", width: 80, isSortable: true, filterable: true }, + { accessor: "id", label: "ID", width: 120, isSortable: true, filterable: true, type: "number" }, { accessor: "name", label: "Name", @@ -46,7 +47,7 @@ const BasicExampleComponent = (props: UniversalTableProps) => {
diff --git a/src/types/TableBodyProps.ts b/src/types/TableBodyProps.ts index 8216321d4..ba4b3d56e 100644 --- a/src/types/TableBodyProps.ts +++ b/src/types/TableBodyProps.ts @@ -2,6 +2,8 @@ import { HeaderObject } from ".."; import TableRow from "./TableRow"; interface TableBodyProps { + currentVisibleRows: TableRow[]; + rowsEnteringTheDom: TableRow[]; mainTemplateColumns: string; pinnedLeftColumns: HeaderObject[]; pinnedLeftTemplateColumns: string; @@ -9,7 +11,6 @@ interface TableBodyProps { pinnedRightColumns: HeaderObject[]; pinnedRightTemplateColumns: string; pinnedRightWidth: number; - rowsToRender: TableRow[]; setScrollTop: (scrollTop: number) => void; setScrollDirection: (direction: "up" | "down" | "none") => void; shouldShowEmptyState: boolean; diff --git a/src/types/TableCellProps.ts b/src/types/TableCellProps.ts index 982763b8f..d5bad30d7 100644 --- a/src/types/TableCellProps.ts +++ b/src/types/TableCellProps.ts @@ -1,8 +1,13 @@ import HeaderObject from "./HeaderObject"; import TableRow from "./TableRow"; -import Cell from "./Cell"; +/** + * TableCell Props - Only includes dynamic values that change frequently + * Static/stable values are accessed via useTableStaticContext() inside the component + * This allows React.memo to work effectively + */ export interface TableCellProps { + // Core cell identification props borderClass?: string; colIndex: number; displayRowNumber: number; @@ -13,6 +18,13 @@ export interface TableCellProps { parentHeader?: HeaderObject; rowIndex: number; tableRow: TableRow; + + // Dynamic context values (change frequently, passed as props) + expandAll: boolean; + isLoading: boolean; + rowsWithSelectedCells: Set; + selectedColumns: Set; + unexpandedRows: Set; } export default TableCellProps;