From 633d1d8bcc57867849eb3a116c7d6fb548a1a4e4 Mon Sep 17 00:00:00 2001 From: Rylan Date: Mon, 9 Feb 2026 20:27:32 +0800 Subject: [PATCH 01/11] fix(Table): determine affix header width --- packages/components/table/BaseTable.tsx | 2 +- packages/components/table/TBody.tsx | 2 +- packages/components/table/hooks/useFixed.ts | 5 ++--- packages/components/table/hooks/useRowspanAndColspan.ts | 7 ++++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index e8a2dc8021..e16d66363b 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -376,7 +376,7 @@ const BaseTable = forwardRef((originalProps, ref) const affixedHeader = Boolean((headerAffixedTop || virtualConfig.isVirtualScroll) && tableWidth.current) && (
{ + useDeepEffect(() => { const scrollWidth = getScrollbarWidthWithCSS(); setScrollbarWidth(scrollWidth); @@ -579,8 +579,7 @@ export default function useFixed( } } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFixedColumn]); + }, [isFixedColumn, isFixedHeader, isWidthOverflow, scrollbarWidth, notNeedThWidthList, data]); const updateTableAfterColumnResize = () => { updateFixedStatus(); diff --git a/packages/components/table/hooks/useRowspanAndColspan.ts b/packages/components/table/hooks/useRowspanAndColspan.ts index b9ee13e1d9..db15e96750 100644 --- a/packages/components/table/hooks/useRowspanAndColspan.ts +++ b/packages/components/table/hooks/useRowspanAndColspan.ts @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { get } from 'lodash-es'; import log from '@tdesign/common-js/log/index'; -import { BaseTableCellParams, BaseTableCol, TableRowData, TableRowspanAndColspanFunc } from '../type'; +import useIsomorphicLayoutEffect from '../../hooks/useLayoutEffect'; +import type { BaseTableCellParams, BaseTableCol, TableRowData, TableRowspanAndColspanFunc } from '../type'; export interface SkipSpansValue { colspan?: number; @@ -80,7 +81,7 @@ export default function useRowspanAndColspan( return map; }; - useEffect(() => { + useIsomorphicLayoutEffect(() => { if (!rowspanAndColspan) return; skipSpansMap.clear(); const result = getSkipSpansMap(data, columns, rowspanAndColspan); From ab6efa19c4ea9c42b8f305340698fb3cd9e74587 Mon Sep 17 00:00:00 2001 From: Rylan Date: Mon, 9 Feb 2026 22:33:57 +0800 Subject: [PATCH 02/11] fix(Table): animation and resize --- packages/components/table/BaseTable.tsx | 7 -- packages/components/table/hooks/useFixed.ts | 74 +++++++++------------ 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index e16d66363b..6441fe216f 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -105,7 +105,6 @@ const BaseTable = forwardRef((originalProps, ref) updateColumnFixedShadow, getThWidthList, updateThWidthList, - addTableResizeObserver, updateTableAfterColumnResize, } = useFixed(props, finalColumns, { paginationAffixRef, @@ -294,12 +293,6 @@ const BaseTable = forwardRef((originalProps, ref) // eslint-disable-next-line react-hooks/exhaustive-deps }, [tableContentRef]); - useEffect( - () => addTableResizeObserver(tableRef.current), - // eslint-disable-next-line react-hooks/exhaustive-deps - [tableRef], - ); - const newData = isPaginateData ? dataSource : data; const renderColGroup = (isFixedHeader = true) => ( diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index eb1def775c..a8f29426db 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -1,14 +1,13 @@ -import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { get, pick, xorWith } from 'lodash-es'; +import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import log from '@tdesign/common-js/log/index'; import { getScrollbarWidthWithCSS } from '@tdesign/common-js/utils/getScrollbarWidth'; import { getIEVersion } from '@tdesign/common-js/utils/helper'; import { off, on } from '../../_util/listener'; -import useDebounce from '../../hooks/useDebounce'; import useDeepEffect from '../../hooks/useDeepEffect'; import usePrevious from '../../hooks/usePrevious'; -import { isLessThanIE11OrNotHaveResizeObserver, resizeObserverElement } from '../utils'; +import { resizeObserverElement } from '../utils'; import type { AffixRef } from '../../affix'; import type { ClassName, Styles } from '../../common'; @@ -369,10 +368,13 @@ export default function useFixed( }; const updateTableWidth = () => { - const rect = tableContentRef.current?.getBoundingClientRect?.(); + const tRef = tableContentRef.current; + const rect = tRef?.getBoundingClientRect?.(); if (!rect) return; - // 存在纵向滚动条,且固定表头时,需去除滚动条宽度 - const reduceWidth = isFixedHeader ? scrollbarWidth : 0; + // 直接从 DOM 判断是否存在纵向溢出,避免依赖异步的 React state + const isCurrentHeightOverflow = tRef.scrollHeight > tRef.clientHeight; + // 去除滚动条宽度 + const reduceWidth = isCurrentHeightOverflow ? scrollbarWidth : 0; tableWidth.current = rect.width - reduceWidth - (props.bordered ? 1 : 0); const elmRect = tableElmRef?.current?.getBoundingClientRect(); if (elmRect?.width) { @@ -488,20 +490,6 @@ export default function useFixed( // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFixedColumn, columns, tableContentRef]); - // 使用防抖函数,避免频繁触发 - const updateFixedHeaderByUseDebounce = useDebounce(() => { - updateFixedHeader(); - }, 30); - - /** - * 通过监测表格大小变化,来调用 updateFixedHeader 修改状态 - */ - useEffect(() => { - if (tableContentRef.current) { - return resizeObserverElement(tableContentRef.current, updateFixedHeaderByUseDebounce); - } - }, [updateFixedHeaderByUseDebounce]); - useDeepEffect(updateFixedHeader, [maxHeight, data, columns, bordered, tableContentRef]); useDeepEffect(() => { @@ -540,23 +528,12 @@ export default function useFixed( } }; - const onResize = useDebounce(() => { - refreshTable(); - }, 30); - - function addTableResizeObserver(tableElement: HTMLDivElement) { - /** - * IE 11 以下使用 window resize;IE 11 以上使用 ResizeObserver - * 抽离相关判断为单独的方法 - */ - if (isLessThanIE11OrNotHaveResizeObserver()) return; - off(window, 'resize', onResize); - if (!tableElmWidth.current) return; - // 抽离 resize 为单独的方法,通过回调来执行操作 - return resizeObserverElement(tableElement, () => { - refreshTable(); - }); - } + useEffect(() => { + if (!tableContentRef.current) return; + // IE 11 以上使用 ResizeObserver + return resizeObserverElement(tableContentRef.current, refreshTable); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useDeepEffect(() => { const scrollWidth = getScrollbarWidthWithCSS(); @@ -567,20 +544,36 @@ export default function useFixed( const hasResizeObserver = hasWindow && typeof window.ResizeObserver !== 'undefined'; updateTableWidth(); updateThWidthListHandler(); - // IE 11 以下使用 window resize;IE 11 以上使用 ResizeObserver + // IE 11 以下使用 window resize if ((isWatchResize && getIEVersion() < 11) || !hasResizeObserver) { - on(window, 'resize', onResize); + on(window, 'resize', refreshTable); } return () => { if ((isWatchResize && getIEVersion() < 11) || !hasResizeObserver) { if (typeof window !== 'undefined') { - off(window, 'resize', onResize); + off(window, 'resize', refreshTable); } } }; }, [isFixedColumn, isFixedHeader, isWidthOverflow, scrollbarWidth, notNeedThWidthList, data]); + useEffect(() => { + // 针对表格放在 Dialog 等有动画效果元素里的场景 + const tableContent = tableContentRef.current; + if (!tableContent) return; + const onAnimationEnd = (e: AnimationEvent) => { + const target = e.target as HTMLElement; + if (!target || !target.contains(tableContent)) return; + refreshTable(); + }; + on(document, 'animationend', onAnimationEnd, { capture: true }); + return () => { + off(document, 'animationend', onAnimationEnd, { capture: true }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const updateTableAfterColumnResize = () => { updateFixedStatus(); updateFixedHeader(); @@ -607,7 +600,6 @@ export default function useFixed( setUseFixedTableElmRef, getThWidthList, updateThWidthList, - addTableResizeObserver, updateTableAfterColumnResize, }; } From 952dee17ac7043ef4f7f0affcabc6747c6b5950b Mon Sep 17 00:00:00 2001 From: Rylan Date: Mon, 9 Feb 2026 22:59:52 +0800 Subject: [PATCH 03/11] fix(Table): horizontalScrollAffixedBottom --- packages/components/table/BaseTable.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 6441fe216f..fa1c42ee69 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -49,6 +49,9 @@ const BaseTable = forwardRef((originalProps, ref) lazyLoad, pagination, } = props; + + const borderWidth = props.bordered ? 1 : 0; + const tableRef = useRef(null); const tableElmRef = useRef(null); const bottomContentRef = useRef(null); @@ -155,9 +158,12 @@ const BaseTable = forwardRef((originalProps, ref) if (!bordered) return; const bottomRect = bottomContentRef.current?.getBoundingClientRect(); const paginationRect = paginationRef.current?.getBoundingClientRect(); - const bottom = (bottomRect?.height || 0) + (paginationRect?.height || 0); + let bottom = (bottomRect?.height || 0) + (paginationRect?.height || 0); + if (props.horizontalScrollAffixedBottom) { + bottom -= scrollbarWidth + borderWidth; + } setDividerBottom(bottom); - }, [bottomContentRef, paginationRef, bordered]); + }, [bottomContentRef, paginationRef, bordered, props.horizontalScrollAffixedBottom, scrollbarWidth, borderWidth]); useEffect(() => { setUseFixedTableElmRef(tableElmRef.current); @@ -346,9 +352,6 @@ const BaseTable = forwardRef((originalProps, ref) props.size, ]; - // 多级表头左边线缺失 - const affixedLeftBorder = props.bordered ? 1 : 0; - // IE浏览器需要遮挡header吸顶滚动条,要减去getBoundingClientRect.height的滚动条高度4像素 const IEHeaderWrap = getIEVersion() <= 11 ? 4 : 0; const affixHeaderHeight = (affixHeaderRef.current?.getBoundingClientRect().height || 0) - IEHeaderWrap; @@ -369,7 +372,7 @@ const BaseTable = forwardRef((originalProps, ref) const affixedHeader = Boolean((headerAffixedTop || virtualConfig.isVirtualScroll) && tableWidth.current) && (
((originalProps, ref) >
((originalProps, ref) tableWidth, tableElmWidth, affixHeaderRef, - affixedLeftBorder, + borderWidth, tableElmClasses, tableElementStyles, columns, @@ -650,7 +653,7 @@ const BaseTable = forwardRef((originalProps, ref) tableElementStyles, tableElmWidth, affixFooterRef, - affixedLeftBorder, + borderWidth, bordered, isWidthOverflow, scrollbarWidth, From 3ccc68a8c4837d1de0ad3c7b61095f368b3a4d21 Mon Sep 17 00:00:00 2001 From: Rylan Date: Tue, 10 Feb 2026 19:10:28 +0800 Subject: [PATCH 04/11] fix(useAffix): scroll container --- packages/components/table/hooks/useAffix.ts | 36 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/components/table/hooks/useAffix.ts b/packages/components/table/hooks/useAffix.ts index c467dbc42e..d21f9886ea 100644 --- a/packages/components/table/hooks/useAffix.ts +++ b/packages/components/table/hooks/useAffix.ts @@ -1,7 +1,7 @@ -import { useState, useRef, useMemo, useEffect } from 'react'; -import { TdBaseTableProps } from '../type'; -import { AffixProps } from '../../affix'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { off, on } from '../../_util/listener'; +import type { AffixProps } from '../../affix'; +import type { TdBaseTableProps } from '../type'; /** * 1. 表头吸顶(普通表头吸顶 和 虚拟滚动表头吸顶) @@ -11,6 +11,8 @@ import { off, on } from '../../_util/listener'; */ export default function useAffix(props: TdBaseTableProps, { showElement }: { showElement: boolean }) { const tableContentRef = useRef(null); + // 自定义滚动容器 + const scrollContainersRef = useRef([]); // 吸顶表头 const affixHeaderRef = useRef(null); // 吸底表尾 @@ -198,14 +200,38 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho } }; + const getAffixContainers = () => { + const containers: HTMLElement[] = []; + const affixConfigs = [props.headerAffixedTop, props.footerAffixedBottom, props.horizontalScrollAffixedBottom]; + for (let i = 0; i < affixConfigs.length; i++) { + const config = affixConfigs[i]; + if (typeof config === 'object' && config && typeof config.container === 'function') { + const el = config.container(); + if (el instanceof HTMLElement && !containers.includes(el)) { + containers.push(el); + } + } + } + return containers; + }; + const addVerticalScrollListener = () => { if (typeof document === 'undefined') return; if (!isAffixed && !props.paginationAffixedBottom) return; const timer = setTimeout(() => { if (isAffixed || props.paginationAffixedBottom) { on(document, 'scroll', onDocumentScroll); + const containers = getAffixContainers(); + scrollContainersRef.current = containers; + for (let i = 0; i < containers.length; i++) { + on(containers[i], 'scroll', onDocumentScroll); + } } else { off(document, 'scroll', onDocumentScroll); + for (let i = 0; i < scrollContainersRef.current.length; i++) { + off(scrollContainersRef.current[i], 'scroll', onDocumentScroll); + } + scrollContainersRef.current = []; } clearTimeout(timer); }); @@ -227,6 +253,10 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho addVerticalScrollListener(); return () => { off(document, 'scroll', onDocumentScroll); + for (let i = 0; i < scrollContainersRef.current.length; i++) { + off(scrollContainersRef.current[i], 'scroll', onDocumentScroll); + } + scrollContainersRef.current = []; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAffixed]); From 96e66388d11f7de777855bab7db027ae0f4c6025 Mon Sep 17 00:00:00 2001 From: Rylan Date: Wed, 11 Feb 2026 15:51:28 +0800 Subject: [PATCH 05/11] fix: affix --- packages/components/affix/Affix.tsx | 68 +++++++++--- packages/components/table/hooks/useAffix.ts | 117 ++++++++++++-------- 2 files changed, 124 insertions(+), 61 deletions(-) diff --git a/packages/components/affix/Affix.tsx b/packages/components/affix/Affix.tsx index c0a1a2b049..1d23e78bd3 100644 --- a/packages/components/affix/Affix.tsx +++ b/packages/components/affix/Affix.tsx @@ -1,11 +1,13 @@ -import React, { useEffect, forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { isFunction } from 'lodash-es'; -import { StyledProps, ScrollContainerElement } from '../common'; -import { TdAffixProps } from './type'; + +import { getScrollContainer } from '../_util/scroll'; import useConfig from '../hooks/useConfig'; -import { affixDefaultProps } from './defaultProps'; import useDefaultProps from '../hooks/useDefaultProps'; -import { getScrollContainer } from '../_util/scroll'; +import { affixDefaultProps } from './defaultProps'; + +import type { ScrollContainerElement, StyledProps } from '../common'; +import type { TdAffixProps } from './type'; export interface AffixProps extends TdAffixProps, StyledProps {} @@ -19,6 +21,8 @@ const Affix = forwardRef((props, ref) => { const { classPrefix } = useConfig(); + const [containerReady, setContainerReady] = useState(false); + const affixRef = useRef(null); const affixWrapRef = useRef(null); const placeholderEL = useRef(null); @@ -114,18 +118,54 @@ const Affix = forwardRef((props, ref) => { }, []); useEffect(() => { - scrollContainer.current = getScrollContainer(container); + const checkContainerExist = () => { + const el = getScrollContainer(container); + const isReady = el instanceof Window || el instanceof HTMLElement; + setContainerReady(isReady); + return isReady; + }; + + if (checkContainerExist()) return; + + const observer = new MutationObserver(() => { + if (checkContainerExist()) { + observer.disconnect(); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, [container]); + + useEffect(() => { + if (!containerReady) return; + + const newContainer = getScrollContainer(container); + if (!newContainer) return; // 容器没准备好 + if (scrollContainer.current === newContainer) return; // 绑定到相同的容器 + + // 清理旧的监听器 if (scrollContainer.current) { - handleScroll(); - scrollContainer.current.addEventListener('scroll', handleScroll); - window.addEventListener('resize', handleScroll); + scrollContainer.current.removeEventListener('scroll', handleScroll); + } + + scrollContainer.current = newContainer; - return () => { + handleScroll(); + scrollContainer.current.addEventListener('scroll', handleScroll); + window.addEventListener('resize', handleScroll); + + return () => { + if (scrollContainer.current) { scrollContainer.current.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', handleScroll); - }; - } - }, [container, handleScroll]); + } + window.removeEventListener('resize', handleScroll); + }; + }, [container, containerReady, handleScroll]); return (
diff --git a/packages/components/table/hooks/useAffix.ts b/packages/components/table/hooks/useAffix.ts index d21f9886ea..430cd6da77 100644 --- a/packages/components/table/hooks/useAffix.ts +++ b/packages/components/table/hooks/useAffix.ts @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { off, on } from '../../_util/listener'; +import { getScrollContainer } from '../../_util/scroll'; import type { AffixProps } from '../../affix'; import type { TdBaseTableProps } from '../type'; @@ -27,6 +28,8 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho const [showAffixFooter, setShowAffixFooter] = useState(true); // 当表格完全滚动消失在视野时,需要隐藏吸底分页器 const [showAffixPagination, setShowAffixPagination] = useState(true); + // 用于记录上一次滚动位置,避免闭包问题 + const lastScrollLeftRef = useRef(0); const isVirtualScroll = useMemo( () => props.scroll && props.scroll.type === 'virtual' && (props.scroll.threshold || 100) < props.data.length, @@ -38,36 +41,53 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho [props.footerAffixedBottom, props.headerAffixedTop, props.horizontalScrollAffixedBottom], ); - let lastScrollLeft = 0; - const onHorizontalScroll = (scrollElement?: HTMLElement) => { - if (!isAffixed && !isVirtualScroll) return; - let target = scrollElement; - if (!target && tableContentRef.current) { - lastScrollLeft = 0; - target = tableContentRef.current; - } - if (!target) return; - const left = target.scrollLeft; - // 如果 lastScrollLeft 等于 left,说明不是横向滚动,不需要更新横向滚动距离 - if (lastScrollLeft === left) return; - lastScrollLeft = left; - // 表格内容、吸顶表头、吸底表尾、吸底横向滚动更新 - const toUpdateScrollElement = [ - tableContentRef.current, - affixHeaderRef.current, - affixFooterRef.current, - horizontalScrollbarRef.current, - ]; - for (let i = 0, len = toUpdateScrollElement.length; i < len; i++) { - if (toUpdateScrollElement[i] && scrollElement !== toUpdateScrollElement[i]) { - toUpdateScrollElement[i].scrollLeft = left; + const onHorizontalScroll = useCallback( + (scrollElement?: HTMLElement) => { + if (!isAffixed && !isVirtualScroll) return; + let target = scrollElement; + if (!target && tableContentRef.current) { + lastScrollLeftRef.current = 0; + target = tableContentRef.current; } - } - }; + if (!target) return; + const left = target.scrollLeft; + // 如果 lastScrollLeft 等于 left,说明不是横向滚动,不需要更新横向滚动距离 + if (lastScrollLeftRef.current === left) return; + lastScrollLeftRef.current = left; + // 表格内容、吸顶表头、吸底表尾、吸底横向滚动更新 + const toUpdateScrollElement = [ + tableContentRef.current, + affixHeaderRef.current, + affixFooterRef.current, + horizontalScrollbarRef.current, + ]; + for (let i = 0, len = toUpdateScrollElement.length; i < len; i++) { + if (toUpdateScrollElement[i] && scrollElement !== toUpdateScrollElement[i]) { + toUpdateScrollElement[i].scrollLeft = left; + } + } + }, + [isAffixed, isVirtualScroll], + ); // 吸底的元素(footer、横向滚动条、分页器)是否显示 - const isAffixedBottomElementShow = (elementRect: DOMRect, tableRect: DOMRect, headerHeight: number) => - tableRect.top + headerHeight < elementRect.top && elementRect.top > elementRect.height; + const isAffixedBottomElementShow = useCallback( + (elementRect: DOMRect, tableRect: DOMRect, headerHeight: number, scrollContainer?: HTMLElement) => { + // 如果有自定义滚动容器,需要相对于容器计算 + if (scrollContainer) { + const containerRect = scrollContainer.getBoundingClientRect(); + const containerBottom = containerRect.bottom; + // 表格内容区域在容器可视区内 + const tableVisibleInContainer = tableRect.top + headerHeight < containerBottom; + // 表格底部超出容器底部(需要吸底滚动条) + const tableBottomBelowContainer = tableRect.bottom > containerBottom; + return tableVisibleInContainer && tableBottomBelowContainer; + } + // 默认相对于 viewport 计算 + return tableRect.top + headerHeight < elementRect.top && elementRect.top > elementRect.height; + }, + [], + ); const getOffsetTop = (props: boolean | Partial) => { if (typeof props === 'boolean') return 0; @@ -200,41 +220,44 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho } }; - const getAffixContainers = () => { + const getAffixContainers = useCallback(() => { const containers: HTMLElement[] = []; const affixConfigs = [props.headerAffixedTop, props.footerAffixedBottom, props.horizontalScrollAffixedBottom]; for (let i = 0; i < affixConfigs.length; i++) { const config = affixConfigs[i]; if (typeof config === 'object' && config && typeof config.container === 'function') { - const el = config.container(); + const el = getScrollContainer(config.container); if (el instanceof HTMLElement && !containers.includes(el)) { containers.push(el); } } } return containers; - }; + }, [props.headerAffixedTop, props.footerAffixedBottom, props.horizontalScrollAffixedBottom]); const addVerticalScrollListener = () => { if (typeof document === 'undefined') return; if (!isAffixed && !props.paginationAffixedBottom) return; - const timer = setTimeout(() => { - if (isAffixed || props.paginationAffixedBottom) { - on(document, 'scroll', onDocumentScroll); - const containers = getAffixContainers(); - scrollContainersRef.current = containers; - for (let i = 0; i < containers.length; i++) { - on(containers[i], 'scroll', onDocumentScroll); - } - } else { - off(document, 'scroll', onDocumentScroll); - for (let i = 0; i < scrollContainersRef.current.length; i++) { - off(scrollContainersRef.current[i], 'scroll', onDocumentScroll); - } - scrollContainersRef.current = []; + + if (isAffixed || props.paginationAffixedBottom) { + on(document, 'scroll', onDocumentScroll); + const containers = getAffixContainers(); + // 移除旧的监听器 + for (let i = 0; i < scrollContainersRef.current.length; i++) { + off(scrollContainersRef.current[i], 'scroll', onDocumentScroll); } - clearTimeout(timer); - }); + scrollContainersRef.current = containers; + for (let i = 0; i < containers.length; i++) { + on(containers[i], 'scroll', onDocumentScroll); + } + updateAffixHeaderOrFooter(); + } else { + off(document, 'scroll', onDocumentScroll); + for (let i = 0; i < scrollContainersRef.current.length; i++) { + off(scrollContainersRef.current[i], 'scroll', onDocumentScroll); + } + scrollContainersRef.current = []; + } }; useEffect(() => { From 8707f69eff63daa756ca5e5fa5c9e3ed6c3b0299 Mon Sep 17 00:00:00 2001 From: Rylan Date: Wed, 11 Feb 2026 18:12:58 +0800 Subject: [PATCH 06/11] fix(Affix): container --- packages/components/affix/Affix.tsx | 65 +++++++++++++------ .../components/affix/__tests__/affix.test.tsx | 18 +++-- packages/components/affix/_example/base.tsx | 13 ++-- .../components/affix/_example/container.tsx | 41 ++++-------- packages/components/affix/affix.en-US.md | 2 +- packages/components/affix/affix.md | 2 +- packages/components/affix/defaultProps.ts | 2 +- packages/components/table/hooks/useAffix.ts | 6 +- packages/components/table/hooks/useFixed.ts | 6 +- test/snap/__snapshots__/csr.test.jsx.snap | 26 ++++++-- test/snap/__snapshots__/ssr.test.jsx.snap | 4 +- 11 files changed, 108 insertions(+), 77 deletions(-) diff --git a/packages/components/affix/Affix.tsx b/packages/components/affix/Affix.tsx index 1d23e78bd3..b587287a14 100644 --- a/packages/components/affix/Affix.tsx +++ b/packages/components/affix/Affix.tsx @@ -1,6 +1,7 @@ -import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { isFunction } from 'lodash-es'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { isWindow } from '../_util/dom'; import { getScrollContainer } from '../_util/scroll'; import useConfig from '../hooks/useConfig'; import useDefaultProps from '../hooks/useDefaultProps'; @@ -37,31 +38,46 @@ const Affix = forwardRef((props, ref) => { // top = 节点到页面顶部的距离,包含 scroll 中的高度 const { top: wrapToTop = 0, + bottom: wrapToBottom = 0, width: wrapWidth = 0, height: wrapHeight = 0, - } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 }; + } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0, bottom: 0 }; // 容器到页面顶部的距离, windows 为0 let containerToTop = 0; - if (scrollContainer.current instanceof HTMLElement) { - containerToTop = scrollContainer.current.getBoundingClientRect().top; + let containerToBottom = 0; + if (isWindow(scrollContainer.current)) { + containerToBottom = scrollContainer.current.innerHeight; + } else if (scrollContainer.current instanceof HTMLElement) { + const rect = scrollContainer.current.getBoundingClientRect(); + containerToTop = rect.top; + containerToBottom = rect.bottom; } const calcTop = wrapToTop - containerToTop; // 节点顶部到 container 顶部的距离 - const containerHeight = - scrollContainer.current?.[scrollContainer.current instanceof Window ? 'innerHeight' : 'clientHeight'] - - wrapHeight; - const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值 let fixedTop: number | false; - if (calcTop <= offsetTop) { - // top 的触发 - fixedTop = containerToTop + offsetTop; - } else if (wrapToTop >= calcBottom) { - // bottom 的触发 - fixedTop = calcBottom; + if (props.offsetBottom !== undefined && props.offsetTop === undefined) { + const bottomThreshold = containerToBottom - (offsetBottom ?? 0); + if (wrapToBottom >= bottomThreshold) { + fixedTop = bottomThreshold - wrapHeight; + } else { + fixedTop = false; + } } else { - fixedTop = false; + const containerHeight = + scrollContainer.current?.[isWindow(scrollContainer.current) ? 'innerHeight' : 'clientHeight'] - + wrapHeight; + const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值 + if (calcTop <= offsetTop) { + // top 的触发 + fixedTop = containerToTop + offsetTop; + } else if (wrapToTop >= calcBottom) { + // bottom 的触发 + fixedTop = calcBottom; + } else { + fixedTop = false; + } } if (affixRef.current) { @@ -106,7 +122,7 @@ const Affix = forwardRef((props, ref) => { }); } ticking.current = true; - }, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]); + }, [classPrefix, offsetBottom, offsetTop, zIndex, onFixedChange, props.offsetBottom, props.offsetTop]); useImperativeHandle(ref, () => ({ handleScroll, @@ -120,7 +136,7 @@ const Affix = forwardRef((props, ref) => { useEffect(() => { const checkContainerExist = () => { const el = getScrollContainer(container); - const isReady = el instanceof Window || el instanceof HTMLElement; + const isReady = isWindow(el) || el instanceof HTMLElement; setContainerReady(isReady); return isReady; }; @@ -138,7 +154,9 @@ const Affix = forwardRef((props, ref) => { subtree: true, }); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + }; }, [container]); useEffect(() => { @@ -146,7 +164,6 @@ const Affix = forwardRef((props, ref) => { const newContainer = getScrollContainer(container); if (!newContainer) return; // 容器没准备好 - if (scrollContainer.current === newContainer) return; // 绑定到相同的容器 // 清理旧的监听器 if (scrollContainer.current) { @@ -159,11 +176,21 @@ const Affix = forwardRef((props, ref) => { scrollContainer.current.addEventListener('scroll', handleScroll); window.addEventListener('resize', handleScroll); + // 当 container 不是 window 时,也需要监听 window 的 scroll 事件 + // 这样当整个页面滚动时,可以确保 affix 元素不会超出容器范围 + const isContainerNotWindow = !isWindow(scrollContainer.current); + if (isContainerNotWindow) { + window.addEventListener('scroll', handleScroll); + } + return () => { if (scrollContainer.current) { scrollContainer.current.removeEventListener('scroll', handleScroll); } window.removeEventListener('resize', handleScroll); + if (isContainerNotWindow) { + window.removeEventListener('scroll', handleScroll); + } }; }, [container, containerReady, handleScroll]); diff --git a/packages/components/affix/__tests__/affix.test.tsx b/packages/components/affix/__tests__/affix.test.tsx index dbfced9398..9174bc8f82 100644 --- a/packages/components/affix/__tests__/affix.test.tsx +++ b/packages/components/affix/__tests__/affix.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, describe, vi, mockTimeout } from '@test/utils'; +import { describe, mockTimeout, render, vi } from '@test/utils'; import Affix from '../index'; describe('Affix 组件测试', () => { @@ -97,12 +97,18 @@ describe('Affix 组件测试', () => { expect(getByText('固钉').parentNode).not.toHaveClass('t-affix'); expect(getByText('固钉').parentElement?.style.zIndex).toBe(''); - // offsetBottom - const isWindow = getByText('固钉').parentElement && window instanceof Window; - const { clientHeight } = document.documentElement; const { innerHeight } = window; - await mockScrollTo((isWindow ? innerHeight : clientHeight) - 40); - await mockScrollTo(isWindow ? innerHeight : clientHeight); + mockFn.mockImplementation(() => ({ + top: innerHeight - 10, + bottom: innerHeight, + left: 0, + right: 0, + height: 10, + width: 0, + x: 0, + y: 0, + toJSON: () => ({}), + })); await mockTimeout(() => false, 200); expect(onFixedChangeMock).toHaveBeenCalledTimes(1); diff --git a/packages/components/affix/_example/base.tsx b/packages/components/affix/_example/base.tsx index 5794b8b055..061d733049 100644 --- a/packages/components/affix/_example/base.tsx +++ b/packages/components/affix/_example/base.tsx @@ -1,16 +1,19 @@ import React, { useState } from 'react'; import { Affix, Button } from 'tdesign-react'; +import type { AffixProps } from 'tdesign-react'; + export default function BaseExample() { - const [top, setTop] = useState(150); + const [affixed, setAffixed] = useState(false); - const handleClick = () => { - setTop(top + 10); + const handleFixedChange: AffixProps['onFixedChange'] = (affixed, { top }) => { + console.log('top', top); + setAffixed(affixed); }; return ( - - + + ); } diff --git a/packages/components/affix/_example/container.tsx b/packages/components/affix/_example/container.tsx index a080f5e226..1cf259bfed 100644 --- a/packages/components/affix/_example/container.tsx +++ b/packages/components/affix/_example/container.tsx @@ -1,38 +1,24 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState } from 'react'; import { Affix, Button } from 'tdesign-react'; -import type { AffixProps } from 'tdesign-react'; export default function ContainerExample() { const [container, setContainer] = useState(null); - const [affixed, setAffixed] = useState(false); - const affixRef = useRef(null); - - const handleFixedChange: AffixProps['onFixedChange'] = (affixed, { top }) => { - console.log('top', top); - setAffixed(affixed); - }; - - useEffect(() => { - if (affixRef.current) { - const { handleScroll } = affixRef.current; - // 防止 affix 移动到容器外 - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - } - }, []); const backgroundStyle = { height: '1500px', - paddingTop: '700px', backgroundColor: '#eee', backgroundImage: 'linear-gradient(45deg,#bbb 25%,transparent 0),linear-gradient(45deg,transparent 75%,#bbb 0),linear-gradient(45deg,#bbb 25%,transparent 0),linear-gradient(45deg,transparent 75%,#bbb 0)', backgroundSize: '30px 30px', backgroundPosition: '0 0,15px 15px,15px 15px,0 0', - }; + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + } as React.CSSProperties; return (
- - + + + + +
diff --git a/packages/components/affix/affix.en-US.md b/packages/components/affix/affix.en-US.md index 6fdcf79381..110381acff 100644 --- a/packages/components/affix/affix.en-US.md +++ b/packages/components/affix/affix.en-US.md @@ -10,7 +10,7 @@ style | Object | - | CSS(Cascading Style Sheets),Typescript: `React.CSSPropert children | TNode | - | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N container | String / Function | () => (() => window) | Typescript: `ScrollContainer`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N content | TNode | - | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N -offsetBottom | Number | 0 | When the distance from the bottom of the container reaches the specified distance, the trigger is fixed | N +offsetBottom | Number | - | When the distance from the bottom of the container reaches the specified distance, the trigger is fixed | N offsetTop | Number | 0 | When the distance from the top of the container reaches the specified distance, the trigger is fixed | N zIndex | Number | - | \- | N onFixedChange | Function | | Typescript: `(affixed: boolean, context: { top: number }) => void`
| N diff --git a/packages/components/affix/affix.md b/packages/components/affix/affix.md index a0fa6a4f62..dca969a380 100644 --- a/packages/components/affix/affix.md +++ b/packages/components/affix/affix.md @@ -10,7 +10,7 @@ style | Object | - | 样式,TS 类型:`React.CSSProperties` | N children | TNode | - | 内容,同 content。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N container | String / Function | () => (() => window) | 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body。TS 类型:`ScrollContainer`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N content | TNode | - | 内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N -offsetBottom | Number | 0 | 距离容器顶部达到指定距离后触发固定 | N +offsetBottom | Number | - | 距离容器顶部达到指定距离后触发固定 | N offsetTop | Number | 0 | 距离容器底部达到指定距离后触发固定 | N zIndex | Number | - | 固钉定位层级,样式默认为 500 | N onFixedChange | Function | | TS 类型:`(affixed: boolean, context: { top: number }) => void`
固定状态发生变化时触发 | N diff --git a/packages/components/affix/defaultProps.ts b/packages/components/affix/defaultProps.ts index 254e262a4b..3bb6e3e422 100644 --- a/packages/components/affix/defaultProps.ts +++ b/packages/components/affix/defaultProps.ts @@ -4,4 +4,4 @@ import { TdAffixProps } from './type'; -export const affixDefaultProps: TdAffixProps = { container: () => window, offsetBottom: 0, offsetTop: 0 }; +export const affixDefaultProps: TdAffixProps = { container: () => window, offsetTop: 0 }; diff --git a/packages/components/table/hooks/useAffix.ts b/packages/components/table/hooks/useAffix.ts index 430cd6da77..83e5ec7bed 100644 --- a/packages/components/table/hooks/useAffix.ts +++ b/packages/components/table/hooks/useAffix.ts @@ -14,6 +14,8 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho const tableContentRef = useRef(null); // 自定义滚动容器 const scrollContainersRef = useRef([]); + // 用于记录上一次滚动位置,避免闭包问题 + const lastScrollLeftRef = useRef(0); // 吸顶表头 const affixHeaderRef = useRef(null); // 吸底表尾 @@ -28,8 +30,6 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho const [showAffixFooter, setShowAffixFooter] = useState(true); // 当表格完全滚动消失在视野时,需要隐藏吸底分页器 const [showAffixPagination, setShowAffixPagination] = useState(true); - // 用于记录上一次滚动位置,避免闭包问题 - const lastScrollLeftRef = useRef(0); const isVirtualScroll = useMemo( () => props.scroll && props.scroll.type === 'virtual' && (props.scroll.threshold || 100) < props.data.length, @@ -225,7 +225,7 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho const affixConfigs = [props.headerAffixedTop, props.footerAffixedBottom, props.horizontalScrollAffixedBottom]; for (let i = 0; i < affixConfigs.length; i++) { const config = affixConfigs[i]; - if (typeof config === 'object' && config && typeof config.container === 'function') { + if (typeof config === 'object' && config?.container) { const el = getScrollContainer(config.container); if (el instanceof HTMLElement && !containers.includes(el)) { containers.push(el); diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index a8f29426db..930529aa42 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -371,10 +371,10 @@ export default function useFixed( const tRef = tableContentRef.current; const rect = tRef?.getBoundingClientRect?.(); if (!rect) return; - // 直接从 DOM 判断是否存在纵向溢出,避免依赖异步的 React state - const isCurrentHeightOverflow = tRef.scrollHeight > tRef.clientHeight; + // 直接从 DOM 判断是否存在纵向溢出,避免依赖异步状态 + const isOverflow = tRef.scrollHeight > tRef.clientHeight; // 去除滚动条宽度 - const reduceWidth = isCurrentHeightOverflow ? scrollbarWidth : 0; + const reduceWidth = isOverflow ? scrollbarWidth : 0; tableWidth.current = rect.width - reduceWidth - (props.bordered ? 1 : 0); const elmRect = tableElmRef?.current?.getBoundingClientRect(); if (elmRect?.width) { diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index 79ee6ef014..9ab8846904 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -11,7 +11,8 @@ exports[`csr snapshot test > csr test packages/components/affix/_example/base.ts - 固钉 + Affixed: + false
@@ -25,7 +26,7 @@ exports[`csr snapshot test > csr test packages/components/affix/_example/contain style="border-radius: 3px; height: 400px; overflow-x: hidden; overflow-y: auto;" >
@@ -36,8 +37,21 @@ exports[`csr snapshot test > csr test packages/components/affix/_example/contain - affixed: - false + Top + + +
+
+
+
+
@@ -150477,9 +150491,9 @@ exports[`csr snapshot test > csr test packages/components/upload/_example/single
`; -exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/alert/_example/base.tsx 1`] = `"
这是一条成功的消息提示
这是一条普通的消息提示
这是一条警示消息
高危操作/出错信息提示
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index dce5e8cc0c..34b2d0e0ab 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/alert/_example/base.tsx 1`] = `"
这是一条成功的消息提示
这是一条普通的消息提示
这是一条警示消息
高危操作/出错信息提示
"`; From 7d50ad5bbdeca7774af69c9e0bc000f1df3e059e Mon Sep 17 00:00:00 2001 From: Rylan Date: Thu, 12 Feb 2026 18:50:27 +0800 Subject: [PATCH 07/11] chore: optimize --- packages/components/table/hooks/useFixed.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index 930529aa42..ff27892e55 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -1,5 +1,5 @@ -import { get, pick, xorWith } from 'lodash-es'; import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { get, pick, xorWith } from 'lodash-es'; import log from '@tdesign/common-js/log/index'; import { getScrollbarWidthWithCSS } from '@tdesign/common-js/utils/getScrollbarWidth'; @@ -371,10 +371,8 @@ export default function useFixed( const tRef = tableContentRef.current; const rect = tRef?.getBoundingClientRect?.(); if (!rect) return; - // 直接从 DOM 判断是否存在纵向溢出,避免依赖异步状态 - const isOverflow = tRef.scrollHeight > tRef.clientHeight; // 去除滚动条宽度 - const reduceWidth = isOverflow ? scrollbarWidth : 0; + const reduceWidth = isWidthOverflow ? scrollbarWidth : 0; tableWidth.current = rect.width - reduceWidth - (props.bordered ? 1 : 0); const elmRect = tableElmRef?.current?.getBoundingClientRect(); if (elmRect?.width) { From f7dbf4230aea26c4032686550667a6ae15286af8 Mon Sep 17 00:00:00 2001 From: Rylan Date: Wed, 25 Feb 2026 15:49:07 +0800 Subject: [PATCH 08/11] chore: reduce diff --- packages/components/table/hooks/useAffix.ts | 53 ++++++++++----------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/packages/components/table/hooks/useAffix.ts b/packages/components/table/hooks/useAffix.ts index 83e5ec7bed..f7c4a5d6da 100644 --- a/packages/components/table/hooks/useAffix.ts +++ b/packages/components/table/hooks/useAffix.ts @@ -14,7 +14,7 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho const tableContentRef = useRef(null); // 自定义滚动容器 const scrollContainersRef = useRef([]); - // 用于记录上一次滚动位置,避免闭包问题 + // 上一次滚动位置 const lastScrollLeftRef = useRef(0); // 吸顶表头 const affixHeaderRef = useRef(null); @@ -41,34 +41,31 @@ export default function useAffix(props: TdBaseTableProps, { showElement }: { sho [props.footerAffixedBottom, props.headerAffixedTop, props.horizontalScrollAffixedBottom], ); - const onHorizontalScroll = useCallback( - (scrollElement?: HTMLElement) => { - if (!isAffixed && !isVirtualScroll) return; - let target = scrollElement; - if (!target && tableContentRef.current) { - lastScrollLeftRef.current = 0; - target = tableContentRef.current; - } - if (!target) return; - const left = target.scrollLeft; - // 如果 lastScrollLeft 等于 left,说明不是横向滚动,不需要更新横向滚动距离 - if (lastScrollLeftRef.current === left) return; - lastScrollLeftRef.current = left; - // 表格内容、吸顶表头、吸底表尾、吸底横向滚动更新 - const toUpdateScrollElement = [ - tableContentRef.current, - affixHeaderRef.current, - affixFooterRef.current, - horizontalScrollbarRef.current, - ]; - for (let i = 0, len = toUpdateScrollElement.length; i < len; i++) { - if (toUpdateScrollElement[i] && scrollElement !== toUpdateScrollElement[i]) { - toUpdateScrollElement[i].scrollLeft = left; - } + const onHorizontalScroll = (scrollElement?: HTMLElement) => { + if (!isAffixed && !isVirtualScroll) return; + let target = scrollElement; + if (!target && tableContentRef.current) { + lastScrollLeftRef.current = 0; + target = tableContentRef.current; + } + if (!target) return; + const left = target.scrollLeft; + // 如果 lastScrollLeft 等于 left,说明不是横向滚动,不需要更新横向滚动距离 + if (lastScrollLeftRef.current === left) return; + lastScrollLeftRef.current = left; + // 表格内容、吸顶表头、吸底表尾、吸底横向滚动更新 + const toUpdateScrollElement = [ + tableContentRef.current, + affixHeaderRef.current, + affixFooterRef.current, + horizontalScrollbarRef.current, + ]; + for (let i = 0, len = toUpdateScrollElement.length; i < len; i++) { + if (toUpdateScrollElement[i] && scrollElement !== toUpdateScrollElement[i]) { + toUpdateScrollElement[i].scrollLeft = left; } - }, - [isAffixed, isVirtualScroll], - ); + } + }; // 吸底的元素(footer、横向滚动条、分页器)是否显示 const isAffixedBottomElementShow = useCallback( From 6b709c32d408891676365a11cfbf6fd1c00927b3 Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 13 Mar 2026 12:56:09 +0800 Subject: [PATCH 09/11] chore: minor adjustment --- packages/components/affix/Affix.tsx | 17 +++-------------- packages/components/table/hooks/useFixed.ts | 2 +- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/components/affix/Affix.tsx b/packages/components/affix/Affix.tsx index b587287a14..d42e261277 100644 --- a/packages/components/affix/Affix.tsx +++ b/packages/components/affix/Affix.tsx @@ -1,5 +1,5 @@ -import { isFunction } from 'lodash-es'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { isFunction } from 'lodash-es'; import { isWindow } from '../_util/dom'; import { getScrollContainer } from '../_util/scroll'; @@ -41,7 +41,7 @@ const Affix = forwardRef((props, ref) => { bottom: wrapToBottom = 0, width: wrapWidth = 0, height: wrapHeight = 0, - } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0, bottom: 0 }; + } = affixWrapRef.current?.getBoundingClientRect() ?? {}; // 容器到页面顶部的距离, windows 为0 let containerToTop = 0; @@ -66,8 +66,7 @@ const Affix = forwardRef((props, ref) => { } } else { const containerHeight = - scrollContainer.current?.[isWindow(scrollContainer.current) ? 'innerHeight' : 'clientHeight'] - - wrapHeight; + scrollContainer.current?.[isWindow(scrollContainer.current) ? 'innerHeight' : 'clientHeight'] - wrapHeight; const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值 if (calcTop <= offsetTop) { // top 的触发 @@ -176,21 +175,11 @@ const Affix = forwardRef((props, ref) => { scrollContainer.current.addEventListener('scroll', handleScroll); window.addEventListener('resize', handleScroll); - // 当 container 不是 window 时,也需要监听 window 的 scroll 事件 - // 这样当整个页面滚动时,可以确保 affix 元素不会超出容器范围 - const isContainerNotWindow = !isWindow(scrollContainer.current); - if (isContainerNotWindow) { - window.addEventListener('scroll', handleScroll); - } - return () => { if (scrollContainer.current) { scrollContainer.current.removeEventListener('scroll', handleScroll); } window.removeEventListener('resize', handleScroll); - if (isContainerNotWindow) { - window.removeEventListener('scroll', handleScroll); - } }; }, [container, containerReady, handleScroll]); diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index ff27892e55..5fa9d6ec45 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -562,7 +562,7 @@ export default function useFixed( if (!tableContent) return; const onAnimationEnd = (e: AnimationEvent) => { const target = e.target as HTMLElement; - if (!target || !target.contains(tableContent)) return; + if (!target?.contains(tableContent)) return; refreshTable(); }; on(document, 'animationend', onAnimationEnd, { capture: true }); From 0071508a533ebf5cca77cc6a1ce496eb84c400a4 Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 13 Mar 2026 14:20:13 +0800 Subject: [PATCH 10/11] revert: built-in listener logic --- packages/components/affix/Affix.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/components/affix/Affix.tsx b/packages/components/affix/Affix.tsx index d42e261277..9ff2d8dff4 100644 --- a/packages/components/affix/Affix.tsx +++ b/packages/components/affix/Affix.tsx @@ -34,7 +34,7 @@ const Affix = forwardRef((props, ref) => { // 这里是通过控制 wrap 的 border-top 到浏览器顶部距离和 offsetTop 比较 const handleScroll = useCallback(() => { if (!ticking.current) { - window.requestAnimationFrame(() => { + requestAnimationFrame(() => { // top = 节点到页面顶部的距离,包含 scroll 中的高度 const { top: wrapToTop = 0, @@ -175,11 +175,21 @@ const Affix = forwardRef((props, ref) => { scrollContainer.current.addEventListener('scroll', handleScroll); window.addEventListener('resize', handleScroll); + // 当 container 不是 window 时,也需要监听 window 的 scroll 事件 + // 这样当整个页面滚动时,可以确保 affix 元素不会超出容器范围 + const isContainerNotWindow = !isWindow(scrollContainer.current); + if (isContainerNotWindow) { + window.addEventListener('scroll', handleScroll); + } + return () => { if (scrollContainer.current) { scrollContainer.current.removeEventListener('scroll', handleScroll); } window.removeEventListener('resize', handleScroll); + if (isContainerNotWindow) { + window.removeEventListener('scroll', handleScroll); + } }; }, [container, containerReady, handleScroll]); From 2e3acaec7942c7a16d628d6658e919fc2e95cc2f Mon Sep 17 00:00:00 2001 From: tdesign-bot Date: Fri, 13 Mar 2026 09:31:40 +0000 Subject: [PATCH 11/11] chore: stash changelog [ci skip] --- packages/tdesign-react/.changelog/pr-4131.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/tdesign-react/.changelog/pr-4131.md diff --git a/packages/tdesign-react/.changelog/pr-4131.md b/packages/tdesign-react/.changelog/pr-4131.md new file mode 100644 index 0000000000..ac6718d981 --- /dev/null +++ b/packages/tdesign-react/.changelog/pr-4131.md @@ -0,0 +1,10 @@ +--- +pr_number: 4131 +contributor: RylanBot +--- + +- fix(Table): 修复虚拟滚动时,合并单元格消失的问题 @RylanBot ([#4131](https://github.com/Tencent/tdesign-react/pull/4131)) +- fix(Table): 修复页面自适应时,`empty` 宽度比超出表格的问题 @RylanBot ([#4131](https://github.com/Tencent/tdesign-react/pull/4131)) +- fix(Table): 修复在 `Dialog` 内使用时,吸顶表头、吸底表尾、吸底滚动条与表格对齐不稳定的问题 @RylanBot ([#4131](https://github.com/Tencent/tdesign-react/pull/4131)) +- fix(Affix): 修复自定义容器时,DOM 节点未准备好就监听导致失败的问题 @RylanBot ([#4131](https://github.com/Tencent/tdesign-react/pull/4131)) +- fix(Affix): 修复自定义容器时,滚动整个页面元素会偏离的问题 @HaixingOoO @RylanBot ([#4131](https://github.com/Tencent/tdesign-react/pull/4131))