diff --git a/src/components/animate/Animate.tsx b/src/components/animate/Animate.tsx index fa9d0cc99..de546a4c0 100644 --- a/src/components/animate/Animate.tsx +++ b/src/components/animate/Animate.tsx @@ -31,11 +31,13 @@ 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); useLayoutEffect(() => { // Early exit if animations are disabled - don't do any work at all @@ -48,31 +50,78 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate return; } - const toBounds = elementRef.current.getBoundingClientRect(); - const fromBounds = fromBoundsRef.current; + let toBounds = elementRef.current.getBoundingClientRect(); + + // DEBUG: Track specific cell + const isDebugCell = id === "3-companyName"; + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Start of useLayoutEffect", { + id, + toBounds: { x: toBounds.x, y: toBounds.y, top: toBounds.top, left: toBounds.left }, + currentTransform: elementRef.current.style.transform, + currentTransition: elementRef.current.style.transition, + }); + } + + // 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 (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Position sources", { + hasCapturedPosition: !!capturedPosition, + capturedPosition: capturedPosition + ? { x: capturedPosition.x, y: capturedPosition.y } + : null, + hasFromBoundsRef: !!fromBoundsRef.current, + fromBoundsRef: fromBoundsRef.current + ? { x: fromBoundsRef.current.x, y: fromBoundsRef.current.y } + : null, + fromBounds: fromBounds ? { x: fromBounds.x, y: fromBounds.y } : null, + }); + } // If we're currently scrolling, don't animate and don't update bounds if (isScrolling) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Scrolling, skipping"); + } return; } // If scrolling just ended, update the previous bounds without animating if (previousScrollingState && !isScrolling) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Scrolling just ended, updating bounds"); + } fromBoundsRef.current = toBounds; return; } // If resizing just ended, update the previous bounds without animating if (previousResizingState && !isResizing) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Resizing just ended, updating bounds"); + } 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) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - No fromBounds, skipping (first render)"); + } return; } @@ -81,8 +130,21 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const deltaY = toBounds.y - fromBounds.y; const positionDelta = Math.abs(deltaX); + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Position deltas", { + deltaX, + deltaY, + positionDelta, + fromBounds: { x: fromBounds.x, y: fromBounds.y }, + toBounds: { x: toBounds.x, y: toBounds.y }, + }); + } + // Only animate if position change is significant (indicates column/row reordering) if (positionDelta < COLUMN_REORDER_THRESHOLD && Math.abs(deltaY) <= ROW_REORDER_THRESHOLD) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Position change too small, skipping"); + } return; } @@ -96,6 +158,69 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate hasPositionChanged = hasDOMPositionChanged; if (hasPositionChanged) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Position changed, starting animation", { + hasDOMPositionChanged, + deltaX, + deltaY, + }); + } + + // 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) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Canceling previous cleanup"); + } + 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) { + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Animation in progress, freezing element"); + } + + // 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; + + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - Freeze details", { + currentVisualY, + pureDOMY, + offsetY, + }); + } + + // 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(); + + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - toBounds after freeze", { + toBounds: { x: toBounds.x, y: toBounds.y }, + }); + } + } + // Merge animation config with defaults const finalConfig = { ...ANIMATION_CONFIGS.ROW_REORDER, @@ -179,6 +304,18 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const animationEndY = parentScrollTop + clientHeight + dynamicDistance + leavingStagger + positionVariance; + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - ANIMATING: Moving below viewport", { + startY: fromBounds.y, + endY: animationEndY, + finalY: toBounds.y, + dynamicDistance, + leavingStagger, + positionVariance, + elementPosition, + }); + } + animateWithCustomCoordinates({ element: elementRef.current, options: { @@ -211,6 +348,18 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const animationEndY = parentScrollTop - dynamicDistance - leavingStagger - positionVariance; + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - ANIMATING: Moving above viewport", { + startY: fromBounds.y, + endY: animationEndY, + finalY: toBounds.y, + dynamicDistance, + leavingStagger, + positionVariance, + elementPosition, + }); + } + animateWithCustomCoordinates({ element: elementRef.current, options: { @@ -243,6 +392,19 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate const animationStartY = parentScrollTop + clientHeight + dynamicDistance + enteringStagger; + if (isDebugCell) { + console.log( + "🔍 [Animate] Cell 3-companyName - ANIMATING: Entering from below viewport", + { + startY: animationStartY, + endY: toBounds.y, + dynamicDistance, + enteringStagger, + elementPosition, + } + ); + } + animateWithCustomCoordinates({ element: elementRef.current, options: { @@ -269,6 +431,19 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate (elementPosition % ENTERING_STAGGER_CYCLE) * baseStagger * ENTERING_STAGGER_MULTIPLIER; const animationStartY = parentScrollTop - dynamicDistance - enteringStagger; + if (isDebugCell) { + console.log( + "🔍 [Animate] Cell 3-companyName - ANIMATING: Entering from above viewport", + { + startY: animationStartY, + endY: toBounds.y, + dynamicDistance, + enteringStagger, + elementPosition, + } + ); + } + animateWithCustomCoordinates({ element: elementRef.current, options: { @@ -284,16 +459,29 @@ export const Animate = ({ children, id, parentRef, tableRow, ...props }: Animate } } + if (isDebugCell) { + console.log("🔍 [Animate] Cell 3-companyName - ANIMATING: Regular flip animation", { + fromBounds: { x: fromBounds.x, y: fromBounds.y }, + toBounds: { x: toBounds.x, y: toBounds.y }, + deltaY: toBounds.y - fromBounds.y, + }); + } + + // Start the animation and store the cleanup function flipElement({ element: elementRef.current, fromBounds, toBounds, finalConfig, + }).then((cleanup) => { + cleanupCallbackRef.current = cleanup; }); } else { } }, [ + id, allowAnimations, + capturedPositionsRef, isResizing, isScrolling, parentRef, diff --git a/src/components/animate/animation-utils.ts b/src/components/animate/animation-utils.ts index 2bfad1556..9d15c34f9 100644 --- a/src/components/animate/animation-utils.ts +++ b/src/components/animate/animation-utils.ts @@ -1,39 +1,16 @@ import CellValue from "../../types/CellValue"; import { AnimationConfig, FlipAnimationOptions, CustomAnimationOptions } from "./types"; -/** - * Check if user prefers reduced motion - */ -export const prefersReducedMotion = (): boolean => { - if (typeof window === "undefined") return false; - return window.matchMedia("(prefers-reduced-motion: reduce)").matches; -}; - /** * 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: 9000, 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,11 +19,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 - - return { ...baseConfig, ...overrides }; + return { ...ANIMATION_CONFIGS.ROW_REORDER, ...overrides }; }; /** @@ -70,14 +43,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"); }; @@ -89,22 +84,21 @@ const cleanupAnimation = (element: HTMLElement) => { element.style.transitionDelay = ""; element.style.transform = ""; element.style.top = ""; - // Clean up performance optimization styles element.style.willChange = ""; element.style.backfaceVisibility = ""; - // Remove animating class to restore normal z-index element.classList.remove("st-animating"); }; /** * 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 => { +): 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,44 +115,52 @@ 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 */ -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 }; - } - +export const getAnimationConfig = (options: FlipAnimationOptions = {}): AnimationConfig => { // Fall back to default config return { ...ANIMATION_CONFIGS.ROW_REORDER, ...options }; }; @@ -167,6 +169,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 +181,33 @@ 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; - } - - // 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 movement type and user preferences - const config = getAnimationConfig(finalConfig, movementType); + const config = getAnimationConfig(finalConfig); - // Clean up any existing animation before starting a new one - cleanupAnimation(element); - - // Apply initial transform with limited values + // 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); - // Animate to final position - await animateToFinalPosition(element, config, finalConfig); + // 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 = ""; + + // Animate to final position and get cleanup function + const cleanup = await animateToFinalPosition(element, config, finalConfig); + + return cleanup; }; /** @@ -228,20 +230,8 @@ export const animateWithCustomCoordinates = async ({ easing = "cubic-bezier(0.2, 0.0, 0.2, 1)", delay = 0, onComplete, - respectReducedMotion = true, } = options; - // Skip animation entirely if user prefers reduced motion and no explicit override - if (prefersReducedMotion() && respectReducedMotion) { - // Jump directly to final position if specified - if (finalY !== undefined) { - element.style.transform = ""; - element.style.top = `${finalY}px`; - } - if (onComplete) onComplete(); - return; - } - // Get element's current position const rect = element.getBoundingClientRect(); const currentY = rect.top; @@ -251,21 +241,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/animate/types.ts b/src/components/animate/types.ts index 64ecd66f3..062fbedda 100644 --- a/src/components/animate/types.ts +++ b/src/components/animate/types.ts @@ -13,7 +13,6 @@ export interface FlipAnimationOptions { maxYLeavingRatio?: number; maxYEnteringRatio?: number; onComplete?: () => void; - respectReducedMotion?: boolean; // Whether to respect user's reduced motion preference (default: true) } export interface CustomAnimationOptions { @@ -24,5 +23,4 @@ export interface CustomAnimationOptions { easing?: string; delay?: number; onComplete?: () => void; - respectReducedMotion?: boolean; } diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx similarity index 100% rename from src/components/Dropdown/Dropdown.tsx rename to src/components/dropdown/Dropdown.tsx diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/dropdown/DropdownItem.tsx similarity index 100% rename from src/components/Dropdown/DropdownItem.tsx rename to src/components/dropdown/DropdownItem.tsx diff --git a/src/components/simple-table/SimpleTable.tsx b/src/components/simple-table/SimpleTable.tsx index c4d6ff9db..7feb3739b 100644 --- a/src/components/simple-table/SimpleTable.tsx +++ b/src/components/simple-table/SimpleTable.tsx @@ -51,6 +51,7 @@ import RowSelectionChangeProps from "../../types/RowSelectionChangeProps"; import CellClickProps from "../../types/CellClickProps"; import { RowButton } from "../../types/RowButton"; import { HeaderDropdown } from "../../types/HeaderDropdownProps"; +import { ANIMATION_CONFIGS } from "../animate/animation-utils"; interface SimpleTableProps { allowAnimations?: boolean; // Flag for allowing animations @@ -118,7 +119,7 @@ const SimpleTable = (props: SimpleTableProps) => { }; const SimpleTableComp = ({ - allowAnimations = false, + allowAnimations = true, cellUpdateFlash = false, className, columnBorders = false, @@ -182,6 +183,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); @@ -317,6 +319,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, @@ -324,19 +339,22 @@ const SimpleTableComp = ({ externalSortHandling, onSortChange, rowGrouping, + onBeforeSort: captureAllPositions, }); // Process rows through pagination, grouping, and virtualization const { currentTableRows, - rowsToRender, + currentVisibleRows, + rowsEnteringTheDom, prepareForFilterChange, prepareForSortChange, + cleanupAnimationRows, isAnimating, + animationStartTime, } = useTableRowProcessing({ allowAnimations, sortedRows, - originalRows: aggregatedRows, currentPage, rowsPerPage, shouldPaginate, @@ -383,18 +401,35 @@ const SimpleTableComp = ({ collapsedHeaders, }); + // Cleanup animation rows after animation completes + useEffect(() => { + if (isAnimating && animationStartTime > 0) { + // Animation duration is 9000ms, add buffer for safety + const timeoutId = setTimeout(() => { + cleanupAnimationRows(); + }, ANIMATION_CONFIGS.ROW_REORDER.duration + 50); + + return () => clearTimeout(timeoutId); + } + }, [isAnimating, animationStartTime, cleanupAnimationRows]); + // Memoize handlers 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[]) => { @@ -423,7 +458,7 @@ const SimpleTableComp = ({ rowIdAccessor, rows, tableRef, - visibleRows: rowsToRender, + visibleRows: currentVisibleRows, }); useExternalFilters({ filters, onFilterChange }); useExternalSort({ sort, onSortChange }); @@ -432,15 +467,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 - internalHandleApplyFilter(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 + internalHandleApplyFilter(filter); + }); + }); }, - [prepareForFilterChange, internalHandleApplyFilter] + [prepareForFilterChange, internalHandleApplyFilter, captureAllPositions] ); return ( @@ -448,6 +490,7 @@ const SimpleTableComp = ({ value={{ allowAnimations, areAllRowsSelected, + capturedPositionsRef, cellRegistry: cellRegistryRef.current, cellUpdateFlash, clearSelection, @@ -551,12 +594,13 @@ const SimpleTableComp = ({ onMouseLeave={handleMouseUp} > { @@ -106,13 +107,13 @@ const TableBody = ({ const indices: RowIndices = {}; // Map each row's ID to its index in the visible rows array - rowsToRender.forEach((rowsToRender, index) => { + currentVisibleRows.forEach((rowsToRender, index) => { const rowId = String(getRowId({ row: rowsToRender.row, rowIdAccessor })); indices[rowId] = index; }); return indices; - }, [rowsToRender, rowIdAccessor]); + }, [currentVisibleRows, rowIdAccessor]); // Check if we should load more data const checkForLoadMore = useCallback( @@ -173,11 +174,12 @@ const TableBody = ({ // Create all props needed for TableSection const commonProps = { columnIndices, + currentVisibleRows, + rowsEnteringTheDom, headerContainerRef, headers, rowHeight, rowIndices, - rowsToRender, setHoveredIndex, }; diff --git a/src/components/simple-table/TableCell.tsx b/src/components/simple-table/TableCell.tsx index a0efbaf0d..d24763f47 100644 --- a/src/components/simple-table/TableCell.tsx +++ b/src/components/simple-table/TableCell.tsx @@ -95,6 +95,8 @@ const TableCell = ({ // Get row ID and check if row has children const rowId = getRowId({ row, rowIdAccessor }); + const cellId = getCellId({ accessor: header.accessor, rowId }); + const currentGroupingKey = rowGrouping && rowGrouping[depth]; const cellHasChildren = currentGroupingKey ? hasNestedRows(row, currentGroupingKey) : false; const isRowExpanded = !unexpandedRows.has(String(rowId)); @@ -132,7 +134,7 @@ const TableCell = ({ const throttle = useThrottle(); // Cell focus id (used for keyboard navigation) - const cellId = getCellId({ accessor: header.accessor, rowId }); + // const cellId = getCellId({ accessor: header.accessor, rowId }); // Generate a unique key that includes the content value to force re-render when it changes const cellKey = getCellKey({ rowId, accessor: header.accessor }); diff --git a/src/components/simple-table/TableContent.tsx b/src/components/simple-table/TableContent.tsx index f2964b194..51cd83950 100644 --- a/src/components/simple-table/TableContent.tsx +++ b/src/components/simple-table/TableContent.tsx @@ -10,21 +10,23 @@ 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: Dispatch>; sort: SortColumn | null; tableRows: TableRow[]; - rowsToRender: TableRow[]; } const TableContent = ({ + currentVisibleRows, + rowsEnteringTheDom, pinnedLeftWidth, pinnedRightWidth, setScrollTop, sort, tableRows, - rowsToRender, }: TableContentLocalProps) => { // Get stable props from context const { columnResizing, editColumns, headers, collapsedHeaders } = useTableContext(); @@ -62,7 +64,8 @@ const TableContent = ({ }; const tableBodyProps: TableBodyProps = { - tableRows, + currentVisibleRows, + rowsEnteringTheDom, mainTemplateColumns, pinnedLeftColumns, pinnedLeftTemplateColumns, @@ -71,7 +74,7 @@ const TableContent = ({ pinnedRightTemplateColumns, pinnedRightWidth, setScrollTop, - rowsToRender, + tableRows, }; return ( diff --git a/src/components/simple-table/TableSection.tsx b/src/components/simple-table/TableSection.tsx index 482018be2..29275d8b0 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,37 @@ const TableSection = forwardRef( ...(!pinned && { flexGrow: 1 }), }} > - {rowsToRender.map((tableRow, index) => { + {currentVisibleRows.map((tableRow, index) => { + const rowId = getRowId({ row: tableRow.row, rowIdAccessor }); + return ( + + {index !== 0 && ( + + )} + + + ); + })} + {rowsEnteringTheDom.map((tableRow, index) => { const rowId = getRowId({ row: tableRow.row, rowIdAccessor }); return ( diff --git a/src/context/TableContext.tsx b/src/context/TableContext.tsx index a05408de6..4933a32c1 100644 --- a/src/context/TableContext.tsx +++ b/src/context/TableContext.tsx @@ -33,6 +33,7 @@ interface TableContextType { activeHeaderDropdown?: HeaderObject | null; allowAnimations?: boolean; areAllRowsSelected?: () => boolean; + capturedPositionsRef: MutableRefObject>; cellRegistry?: Map; cellUpdateFlash?: boolean; clearSelection?: () => void; diff --git a/src/hooks/useSortableData.ts b/src/hooks/useSortableData.ts index f331c2462..09bce26a2 100644 --- a/src/hooks/useSortableData.ts +++ b/src/hooks/useSortableData.ts @@ -48,12 +48,14 @@ const useSortableData = ({ externalSortHandling, onSortChange, rowGrouping, + onBeforeSort, }: { headers: HeaderObject[]; tableRows: Row[]; externalSortHandling: boolean; onSortChange?: (sort: SortColumn | null) => void; rowGrouping?: string[]; + onBeforeSort?: () => void; }) => { // Single sort state instead of complex 3-state system const [sort, setSort] = useState(null); @@ -154,10 +156,13 @@ const useSortableData = ({ }; } + // 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 b981735bd..ff327903b 100644 --- a/src/hooks/useTableRowProcessing.ts +++ b/src/hooks/useTableRowProcessing.ts @@ -1,17 +1,15 @@ -import { useMemo, useState, useLayoutEffect, useCallback, useRef } from "react"; +import { useMemo, useState, useCallback } from "react"; import { BUFFER_ROW_COUNT } from "../consts/general-consts"; import { getVisibleRows } from "../utils/infiniteScrollUtils"; import { flattenRowsWithGrouping, getRowId } from "../utils/rowUtils"; -import { ANIMATION_CONFIGS } from "../components/animate/animation-utils"; import Row from "../types/Row"; import { Accessor } from "../types/HeaderObject"; import { FilterCondition } from "../types/FilterTypes"; +import TableRow from "../types/TableRow"; interface UseTableRowProcessingProps { allowAnimations: boolean; sortedRows: Row[]; - // Original unfiltered rows for establishing baseline positions - originalRows: Row[]; currentPage: number; rowsPerPage: number; shouldPaginate: boolean; @@ -30,7 +28,6 @@ interface UseTableRowProcessingProps { const useTableRowProcessing = ({ allowAnimations, sortedRows, - originalRows, currentPage, rowsPerPage, shouldPaginate, @@ -45,18 +42,16 @@ 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([]); - // 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: any[]; - visibleRows: any[]; - } | null>(null); + // Cleanup function to reset animation states + const cleanupAnimationRows = useCallback(() => { + setRowsEnteringTheDom([]); + setRowsLeavingTheDom([]); + setIsAnimating(false); + }, []); // Process rows through pagination and grouping const processRowSet = useCallback( @@ -96,21 +91,6 @@ const useTableRowProcessing = ({ ] ); - // Establish original positions from unfiltered/unsorted data - useMemo(() => { - // Only set original positions once when component first loads - if (originalPositionsRef.current.size === 0) { - const originalProcessedRows = processRowSet(originalRows); - const newOriginalPositions = new Map(); - originalProcessedRows.forEach((tableRow) => { - const id = String(getRowId({ row: tableRow.row, rowIdAccessor })); - newOriginalPositions.set(id, tableRow.position); - }); - - originalPositionsRef.current = newOriginalPositions; - } - }, [originalRows, processRowSet, rowIdAccessor]); - // Current table rows (processed for display) const currentTableRows = useMemo(() => { // Use sortedRows which already have filters applied @@ -128,175 +108,34 @@ const useTableRowProcessing = ({ }); }, [currentTableRows, contentHeight, rowHeight, scrollTop]); - // Categorize rows based on ID changes - const categorizeRows = useCallback( - (previousRows: any[], currentRows: any[]) => { - const previousIds = new Set( - previousRows.map((row) => String(getRowId({ row: row.row, rowIdAccessor }))) - ); - const currentIds = new Set( - currentRows.map((row) => String(getRowId({ row: row.row, rowIdAccessor }))) - ); - - const staying = currentRows.filter((row) => { - const id = String(getRowId({ row: row.row, rowIdAccessor })); - return previousIds.has(id); - }); - - const entering = currentRows.filter((row) => { - const id = String(getRowId({ row: row.row, rowIdAccessor })); - return !previousIds.has(id); - }); - - const leaving = previousRows.filter((row) => { - const id = String(getRowId({ row: row.row, rowIdAccessor })); - 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((row) => - String(getRowId({ row: row.row, rowIdAccessor })) - ); - const previousIds = previousTableRowsRef.current.map((row) => - String(getRowId({ row: row.row, rowIdAccessor })) + // 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((row) => String(getRowId({ row: row.row, rowIdAccessor }))) ); - 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; - } - - // Always sync when not animating - if (!allowAnimations || shouldPaginate) { - setExtendedRows([]); // Clear extended rows to use normal virtualization - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // Initialize on first render - if (previousTableRowsRef.current.length === 0) { - setExtendedRows([]); // Clear extended rows to use normal virtualization - previousTableRowsRef.current = currentTableRows; - previousVisibleRowsRef.current = targetVisibleRows; - return; - } - - // Check if rows actually changed - this detects STAGE 2 (after sort/filter applied) - if (!hasRowChanges) { - setExtendedRows([]); // Clear extended rows to use normal virtualization - 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, - 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(); - currentTableRows.forEach((row) => { - const id = String(getRowId({ row: row.row, rowIdAccessor })); - positionMap.set(id, row.position); - }); - - // Update ALL rows in extendedRows with their new positions - const updatedExtendedRows = extendedRows.map((row) => { - const id = String(getRowId({ row: row.row, rowIdAccessor })); - const newPosition = positionMap.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) { - return { ...row, position: newPosition }; - } - return row; - }); - - return updatedExtendedRows; - } + // Filter out any rows from targetVisibleRows that are already in rowsLeavingTheDom + const uniqueTargetRows = targetVisibleRows.filter((row) => { + const id = String(getRowId({ row: row.row, rowIdAccessor })); + 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: any) => { + (filter: any, capturePositions?: () => void) => { if (!allowAnimations || shouldPaginate) 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 const newFilteredRows = computeFilteredRowsPreview(filter); const newProcessedRows = processRowSet(newFilteredRows); @@ -308,25 +147,95 @@ const useTableRowProcessing = ({ scrollTop, }); - // 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) => { - const id = String(getRowId({ row: enteringRow.row, rowIdAccessor })); - // Find this row in the current table state to get its original position + // 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: newVisibleRow.row, rowIdAccessor })); + // Find this row in the CURRENT table state (before filter) to get its current position const currentStateRow = currentTableRows.find( (currentRow) => String(getRowId({ row: currentRow.row, rowIdAccessor })) === id ); - return currentStateRow || enteringRow; // Fallback to enteringRow if not found + return currentStateRow || newVisibleRow; // Fallback to newVisibleRow if not found in current state }) .filter(Boolean); - 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((row) => String(getRowId({ row: row.row, rowIdAccessor }))) + ); + + // Create set of IDs already in existingRows + const existingRowIds = new Set( + existingRows.map((row) => String(getRowId({ row: row.row, rowIdAccessor }))) + ); + + // Filter to only include rows that aren't already in targetVisibleRows or existingRows + const uniqueNewRows = rowsInCurrentPosition.filter((row) => { + const id = String(getRowId({ row: row.row, rowIdAccessor })); + 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 be visible after filter) + setRowsLeavingTheDom((existingRows) => { + // Create set of IDs that will be visible after filter + const newVisibleIds = new Set( + newVisibleRows.map((row) => String(getRowId({ row: row.row, rowIdAccessor }))) + ); + + // Create map of existing leaving rows for quick lookup + const existingRowsMap = new Map( + existingRows.map((row) => [String(getRowId({ row: row.row, rowIdAccessor })), row]) + ); + + // Find rows from targetVisibleRows that won't be visible after filter + const candidateLeavingRows = targetVisibleRows.filter((row) => { + const id = String(getRowId({ row: row.row, rowIdAccessor })); + return !newVisibleIds.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 })); + // Find this row in the NEW processed rows to get its NEW position (after filter) + const rowInNewState = newProcessedRows.find( + (newRow) => String(getRowId({ row: newRow.row, rowIdAccessor })) === id + ); + const rowWithNewPosition = rowInNewState || leavingRow; + + if (existingRowsMap.has(id)) { + // Row is already leaving, update its position + 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((row) => { + const id = String(getRowId({ row: row.row, rowIdAccessor })); + const updatedRow = rowsToUpdate.find( + (r) => String(getRowId({ row: r.row, rowIdAccessor })) === id + ); + return updatedRow || row; + }); + + // Combine updated existing rows with new leaving rows + return [...updatedExistingRows, ...newLeavingRows]; + }); }, [ allowAnimations, @@ -336,7 +245,6 @@ const useTableRowProcessing = ({ contentHeight, rowHeight, scrollTop, - categorizeRows, currentTableRows, targetVisibleRows, rowIdAccessor, @@ -344,9 +252,15 @@ const useTableRowProcessing = ({ ); const prepareForSortChange = useCallback( - (accessor: Accessor) => { + (accessor: Accessor, targetVisibleRows: TableRow[], capturePositions?: () => void) => { if (!allowAnimations || shouldPaginate) 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 const newSortedRows = computeSortedRowsPreview(accessor); const newProcessedRows = processRowSet(newSortedRows); @@ -358,25 +272,95 @@ const useTableRowProcessing = ({ scrollTop, }); - // 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) => { - const id = String(getRowId({ row: enteringRow.row, rowIdAccessor })); - // Find this row in the current table state to get its original position + // 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: newVisibleRow.row, rowIdAccessor })); + // Find this row in the CURRENT table state (before sort) to get its current position const currentStateRow = currentTableRows.find( (currentRow) => String(getRowId({ row: currentRow.row, rowIdAccessor })) === id ); - return currentStateRow || enteringRow; // Fallback to enteringRow if not found + return currentStateRow || newVisibleRow; // Fallback to newVisibleRow if not found in current state }) .filter(Boolean); - 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((row) => String(getRowId({ row: row.row, rowIdAccessor }))) + ); + + // Create set of IDs already in existingRows + const existingRowIds = new Set( + existingRows.map((row) => String(getRowId({ row: row.row, rowIdAccessor }))) + ); + + // Filter to only include rows that aren't already in targetVisibleRows or existingRows + const uniqueNewRows = rowsInCurrentPosition.filter((row) => { + const id = String(getRowId({ row: row.row, rowIdAccessor })); + 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 be visible after sort) + setRowsLeavingTheDom((existingRows) => { + // Create set of IDs that will be visible after sort + const newVisibleIds = new Set( + newVisibleRows.map((row) => String(getRowId({ row: row.row, rowIdAccessor }))) + ); + + // Create map of existing leaving rows for quick lookup + const existingRowsMap = new Map( + existingRows.map((row) => [String(getRowId({ row: row.row, rowIdAccessor })), row]) + ); + + // Find rows from targetVisibleRows that won't be visible after sort + const candidateLeavingRows = targetVisibleRows.filter((row) => { + const id = String(getRowId({ row: row.row, rowIdAccessor })); + return !newVisibleIds.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 })); + // Find this row in the NEW processed rows to get its NEW position (after sort) + const rowInNewState = newProcessedRows.find( + (newRow) => String(getRowId({ row: newRow.row, rowIdAccessor })) === id + ); + const rowWithNewPosition = rowInNewState || leavingRow; + + if (existingRowsMap.has(id)) { + // Row is already leaving, update its position + 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((row) => { + const id = String(getRowId({ row: row.row, rowIdAccessor })); + const updatedRow = rowsToUpdate.find( + (r) => String(getRowId({ row: r.row, rowIdAccessor })) === id + ); + return updatedRow || row; + }); + + // Combine updated existing rows with new leaving rows + return [...updatedExistingRows, ...newLeavingRows]; + }); }, [ allowAnimations, @@ -386,20 +370,20 @@ const useTableRowProcessing = ({ contentHeight, rowHeight, scrollTop, - categorizeRows, currentTableRows, - targetVisibleRows, rowIdAccessor, ] ); return { currentTableRows, - currentVisibleRows: targetVisibleRows, + currentVisibleRows: visibleRowsWithLeaving, isAnimating, + animationStartTime, prepareForFilterChange, prepareForSortChange, - rowsToRender, + cleanupAnimationRows, + rowsEnteringTheDom, }; }; diff --git a/src/stories/examples/finance-example/finance-headers.tsx b/src/stories/examples/finance-example/finance-headers.tsx index 7224b7da3..27a14bb58 100644 --- a/src/stories/examples/finance-example/finance-headers.tsx +++ b/src/stories/examples/finance-example/finance-headers.tsx @@ -79,7 +79,6 @@ export const HEADERS: HeaderObject[] = [ label: "Fundamentals", width: 380, isSortable: false, - collapsible: true, children: [ { accessor: "marketCap", diff --git a/src/stories/examples/music/MusicExample.tsx b/src/stories/examples/music/MusicExample.tsx index d8898de57..2181407dc 100644 --- a/src/stories/examples/music/MusicExample.tsx +++ b/src/stories/examples/music/MusicExample.tsx @@ -10,7 +10,6 @@ import data from "./music-data.json"; export const musicExampleDefaults = { columnResizing: true, height: "70dvh", - theme: "frost", }; export default function MusicExample({ diff --git a/src/types/TableBodyProps.ts b/src/types/TableBodyProps.ts index e8fb166d9..b04227314 100644 --- a/src/types/TableBodyProps.ts +++ b/src/types/TableBodyProps.ts @@ -1,10 +1,11 @@ -import { RefObject } from "react"; import { HeaderObject } from ".."; import { Dispatch } from "react"; import { SetStateAction } from "react"; import TableRow from "./TableRow"; interface TableBodyProps { + currentVisibleRows: TableRow[]; + rowsEnteringTheDom: TableRow[]; mainTemplateColumns: string; pinnedLeftColumns: HeaderObject[]; pinnedLeftTemplateColumns: string; @@ -12,7 +13,6 @@ interface TableBodyProps { pinnedRightColumns: HeaderObject[]; pinnedRightTemplateColumns: string; pinnedRightWidth: number; - rowsToRender: TableRow[]; setScrollTop: Dispatch>; tableRows: TableRow[]; }