From 142ef686323d9bb300171882235abdd5ca2049df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 2 Jul 2026 15:33:45 +0800 Subject: [PATCH] fix: sync table scroll by hover source --- src/FixedHolder/index.tsx | 19 +++++++++-- src/Table.tsx | 62 ++++++++++++++++++++++++++++++++--- src/VirtualTable/BodyGrid.tsx | 5 ++- src/VirtualTable/index.tsx | 6 ++-- src/interface.ts | 12 +++---- src/stickyScrollBar.tsx | 8 +++-- tests/Virtual.spec.tsx | 36 ++++++++++++++++++++ 7 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/FixedHolder/index.tsx b/src/FixedHolder/index.tsx index fdcc1132f..a79aa2a14 100644 --- a/src/FixedHolder/index.tsx +++ b/src/FixedHolder/index.tsx @@ -7,7 +7,14 @@ import ColGroup from '../ColGroup'; import TableContext from '../context/TableContext'; import type { HeaderProps } from '../Header/Header'; import devRenderTimes from '../hooks/useRenderTimes'; -import type { ColumnsType, ColumnType, Direction, TableLayout } from '../interface'; +import type { + ColumnsType, + ColumnType, + Direction, + OnCustomizeScroll, + ScrollSource, + TableLayout, +} from '../interface'; function useColumnWidth(colWidths: readonly number[], columCount: number) { return useMemo(() => { @@ -38,7 +45,9 @@ export interface FixedHeaderProps extends HeaderProps { stickyClassName?: string; scrollX?: number | string | true; tableLayout?: TableLayout; - onScroll: (info: { currentTarget: HTMLDivElement; scrollLeft?: number }) => void; + onScroll: OnCustomizeScroll; + onMouseEnter?: React.MouseEventHandler; + scrollSource: Extract; children: (info: HeaderProps) => React.ReactNode; colGroup?: React.ReactNode; } @@ -66,6 +75,8 @@ const FixedHolder = React.forwardRef>((pro scrollX, tableLayout = 'fixed', onScroll, + onMouseEnter, + scrollSource, maxContentScroll, children, ...restProps @@ -109,6 +120,7 @@ const FixedHolder = React.forwardRef>((pro onScroll({ currentTarget, scrollLeft: nextScroll, + source: scrollSource, }); e.preventDefault(); } @@ -120,7 +132,7 @@ const FixedHolder = React.forwardRef>((pro return () => { scrollEle?.removeEventListener('wheel', onWheel); }; - }, []); + }, [direction, onScroll, scrollSource]); // Add scrollbar column const lastColumn = flattenColumns[flattenColumns.length - 1]; @@ -175,6 +187,7 @@ const FixedHolder = React.forwardRef>((pro ...style, }} ref={setScrollRef} + onMouseEnter={onMouseEnter} className={clsx(className, { [stickyClassName]: !!stickyClassName, })} diff --git a/src/Table.tsx b/src/Table.tsx index c59c125e9..bcc44c1df 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -68,6 +68,7 @@ import type { PanelRender, Reference, RowClassName, + ScrollSource, TableComponents, TableLayout, TableSticky, @@ -91,6 +92,18 @@ const EMPTY_DATA = []; // Used for customize scroll const EMPTY_SCROLL_TARGET = {}; +const getScrollSource = ( + source: ScrollSource | undefined, + currentTarget: HTMLElement | undefined, + refs: Record, +): ScrollSource | undefined => { + if (source) { + return source; + } + + return (Object.keys(refs) as ScrollSource[]).find(key => refs[key] === currentTarget); +}; + export type SemanticName = 'section' | 'title' | 'footer' | 'content'; export type ComponentsSemantic = 'wrapper' | 'cell' | 'row'; @@ -350,6 +363,13 @@ const Table = ( const scrollHeaderRef = React.useRef(null); const scrollBodyRef = React.useRef(null); const scrollBodyContainerRef = React.useRef(null); + const [hoverEle, setHoverEle] = React.useState(null); + const onHoverScrollSource = React.useCallback( + (source: ScrollSource) => () => { + setHoverEle(source); + }, + [], + ); React.useImperativeHandle(ref, () => { return { @@ -476,12 +496,32 @@ const Table = ( const [scrollInfo, setScrollInfo] = React.useState([0, 0]); const onInternalScroll = useEvent( - ({ currentTarget, scrollLeft }: { currentTarget: HTMLElement; scrollLeft?: number }) => { + ({ + currentTarget, + scrollLeft, + source, + }: { + currentTarget?: HTMLElement; + scrollLeft?: number; + source?: ScrollSource; + }) => { + const scrollSource = getScrollSource(source, currentTarget, { + body: getDOM(scrollBodyRef.current) as HTMLElement, + header: scrollHeaderRef.current, + summary: scrollSummaryRef.current, + sticky: undefined, + }); + + if (source && hoverEle && scrollSource && hoverEle !== scrollSource) { + return; + } + const mergedScrollLeft = - typeof scrollLeft === 'number' ? scrollLeft : currentTarget.scrollLeft; + typeof scrollLeft === 'number' ? scrollLeft : (currentTarget?.scrollLeft ?? 0); const compareTarget = currentTarget || EMPTY_SCROLL_TARGET; - if (!getScrollTarget() || getScrollTarget() === compareTarget) { + const isHoverSource = hoverEle && scrollSource && hoverEle === scrollSource; + if (isHoverSource || !getScrollTarget() || getScrollTarget() === compareTarget) { setScrollTarget(compareTarget); forceScroll(mergedScrollLeft, scrollHeaderRef.current); @@ -517,8 +557,12 @@ const Table = ( }, ); + const onBodyInternalScroll = useEvent((e: React.UIEvent) => { + onInternalScroll({ currentTarget: e.currentTarget, source: 'body' }); + }); + const onBodyScroll = useEvent((e: React.UIEvent) => { - onInternalScroll(e); + onBodyInternalScroll(e); onScroll?.(e); }); @@ -676,6 +720,7 @@ const Table = ( scrollbarSize, ref: scrollBodyRef, onScroll: onInternalScroll, + onMouseEnter: onHoverScrollSource('body'), }); headerProps.colWidths = flattenColumns.map(({ width }, index) => { @@ -701,6 +746,7 @@ const Table = ( ...scrollYStyle, }} onScroll={onBodyScroll} + onMouseEnter={onHoverScrollSource('body')} ref={scrollBodyRef} className={`${prefixCls}-body`} > @@ -747,6 +793,8 @@ const Table = ( className={`${prefixCls}-header`} ref={scrollHeaderRef} colGroup={bodyColGroup} + scrollSource="header" + onMouseEnter={onHoverScrollSource('header')} > {renderFixedHeaderTable} @@ -763,6 +811,8 @@ const Table = ( className={`${prefixCls}-summary`} ref={scrollSummaryRef} colGroup={bodyColGroup} + scrollSource="summary" + onMouseEnter={onHoverScrollSource('summary')} > {renderFixedFooterTable} @@ -774,6 +824,7 @@ const Table = ( offsetScroll={offsetScroll} scrollBodyRef={scrollBodyRef} onScroll={onInternalScroll} + onMouseEnter={onHoverScrollSource('sticky')} container={container} direction={direction} /> @@ -786,7 +837,8 @@ const Table = (
= { export interface GridProps { data: RecordType[]; onScroll: OnCustomizeScroll; + onMouseEnter?: React.MouseEventHandler; } export interface GridRef { @@ -30,7 +31,7 @@ export interface GridRef { } const Grid = React.forwardRef((props, ref) => { - const { data, onScroll } = props; + const { data, onScroll, onMouseEnter } = props; const { flattenColumns, @@ -276,10 +277,12 @@ const Grid = React.forwardRef((props, ref) => { component={wrapperComponent} scrollWidth={scrollX as number} direction={direction} + onMouseEnter={onMouseEnter} onVirtualScroll={({ x }) => { onScroll({ currentTarget: listRef.current?.nativeElement, scrollLeft: x, + source: 'body', }); }} onScroll={onTablePropScroll} diff --git a/src/VirtualTable/index.tsx b/src/VirtualTable/index.tsx index 8bcd08a93..c41c226fb 100644 --- a/src/VirtualTable/index.tsx +++ b/src/VirtualTable/index.tsx @@ -9,8 +9,10 @@ import Grid from './BodyGrid'; import { StaticContext } from './context'; const renderBody: CustomizeScrollBody = (rawData, props) => { - const { ref, onScroll } = props; - return ; + const { ref, onScroll, onMouseEnter } = props; + return ( + + ); }; export interface VirtualTableProps extends Omit, 'scroll'> { diff --git a/src/interface.ts b/src/interface.ts index d5a8aee03..2a3aa5ed8 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -90,10 +90,7 @@ export type Direction = 'ltr' | 'rtl'; export type SpecialString = T | (string & NonNullable); export type DataIndex = - | DeepNamePath - | SpecialString - | number - | (SpecialString | number)[]; + DeepNamePath | SpecialString | number | (SpecialString | number)[]; export type CellEllipsisType = { showTitle?: boolean } | boolean; @@ -139,8 +136,7 @@ export interface ColumnType extends ColumnSharedType { } export type ColumnsType = readonly ( - | ColumnGroupType - | ColumnType + ColumnGroupType | ColumnType )[]; export type GetRowKey = (record: RecordType, index?: number) => Key; @@ -167,9 +163,12 @@ type Component

= export type CustomizeComponent = Component; +export type ScrollSource = 'body' | 'header' | 'summary' | 'sticky'; + export type OnCustomizeScroll = (info: { currentTarget?: HTMLElement; scrollLeft?: number; + source?: ScrollSource; }) => void; export type CustomizeScrollBody = ( @@ -178,6 +177,7 @@ export type CustomizeScrollBody = ( scrollbarSize: number; ref: React.Ref<{ scrollLeft: number; scrollTo?: (scrollConfig: ScrollConfig) => void }>; onScroll: OnCustomizeScroll; + onMouseEnter?: React.MouseEventHandler; }, ) => React.ReactNode; diff --git a/src/stickyScrollBar.tsx b/src/stickyScrollBar.tsx index 70be1bad9..422e7db5e 100644 --- a/src/stickyScrollBar.tsx +++ b/src/stickyScrollBar.tsx @@ -4,6 +4,7 @@ import { getDOM, getScrollBarSize, raf } from '@rc-component/util'; import * as React from 'react'; import TableContext from './context/TableContext'; import { useLayoutState } from './hooks/useFrame'; +import type { OnCustomizeScroll } from './interface'; import { getOffset } from './utils/offsetUtil'; const MOUSEUP_EVENT: keyof WindowEventMap = 'mouseup'; @@ -13,7 +14,8 @@ const RESIZE_EVENT: keyof WindowEventMap = 'resize'; interface StickyScrollBarProps { scrollBodyRef: React.RefObject; - onScroll: (params: { scrollLeft?: number }) => void; + onScroll: OnCustomizeScroll; + onMouseEnter?: React.MouseEventHandler; offsetScroll: number; container: HTMLElement | Window; direction: string; @@ -23,7 +25,7 @@ const StickyScrollBar: React.ForwardRefRenderFunction { - const { scrollBodyRef, onScroll, offsetScroll, container, direction } = props; + const { scrollBodyRef, onScroll, onMouseEnter, offsetScroll, container, direction } = props; const prefixCls = useContext(TableContext, 'prefixCls'); const bodyScrollWidth = scrollBodyRef.current?.scrollWidth || 0; const bodyWidth = scrollBodyRef.current?.clientWidth || 0; @@ -80,6 +82,7 @@ const StickyScrollBar: React.ForwardRefRenderFunction

{ expect(scrollLeftCalled).toBeTruthy(); }); + it('should skip scroll from non-hovered source', () => { + const { container } = getTable(); + + resize(container.querySelector('.rc-table')!); + + fireEvent.mouseEnter(container.querySelector('.rc-table-header')!); + + setScrollLeft.mockClear(); + + fireEvent.wheel(container.querySelector('.rc-table-tbody-virtual-holder')!, { + deltaX: 10, + }); + + expect(setScrollLeft).not.toHaveBeenCalled(); + }); + + it('should use hovered body as scroll source even when header is locked', () => { + const { container } = getTable(); + + resize(container.querySelector('.rc-table')!); + + fireEvent.mouseEnter(container.querySelector('.rc-table-header')!); + fireEvent.wheel(container.querySelector('.rc-table-header')!, { + deltaX: 10, + }); + + setScrollLeft.mockClear(); + + fireEvent.mouseEnter(container.querySelector('.rc-table-tbody-virtual')!); + fireEvent.wheel(container.querySelector('.rc-table-tbody-virtual-holder')!, { + deltaX: 10, + }); + + expect(setScrollLeft).toHaveBeenCalled(); + }); + it('should not reset scroll when data changed', async () => { const { container, rerender } = getTable();