diff --git a/.github/workflows/pr-compressed-size.yml b/.github/workflows/pr-compressed-size.yml index 5765545a0a..36a084c287 100644 --- a/.github/workflows/pr-compressed-size.yml +++ b/.github/workflows/pr-compressed-size.yml @@ -21,7 +21,7 @@ jobs: - uses: preactjs/compressed-size-action@v2 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - install-script: "pnpm install" + install-script: "bash -c 'rm -f pnpm-lock.yaml && pnpm install --no-frozen-lockfile'" build-script: "build" pattern: "packages/tdesign-react/dist/**/*.{js,css}" comment-key: tdesign-react @@ -42,7 +42,7 @@ jobs: - uses: preactjs/compressed-size-action@v2 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - install-script: "pnpm install" + install-script: "bash -c 'rm -f pnpm-lock.yaml && pnpm install --no-frozen-lockfile'" build-script: "build:aigc" pattern: "packages/tdesign-react-aigc/es/**/*.{js,css}" comment-key: tdesign-react-chat \ No newline at end of file diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 6e41e9ddf3..47f639890e 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -1,4 +1,13 @@ -import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import classNames from 'classnames'; import { pick } from 'lodash-es'; import log from '@tdesign/common-js/log/index'; @@ -23,6 +32,13 @@ import TFoot from './TFoot'; import THead from './THead'; import { ROW_LISTENERS } from './TR'; import { getAffixProps } from './utils'; +import { + hasFixedColumnNeedReorder, + isSameDisplayColumns, + readFixedLayoutScrollMetricsFromElement, + resolveFixedColumnLayout, +} from './utils/reorderFixedColumns'; +import { scheduleAfterColumnDomUpdate } from './utils/scheduleAfterColumnDomUpdate'; import type { RefAttributes } from 'react'; import type { AffixRef } from '../affix'; @@ -30,7 +46,8 @@ import type { Styles } from '../common'; import type { BaseTableProps, BaseTableRef } from './interface'; import type { TableBodyProps } from './TBody'; import type { TheadProps } from './THead'; -import type { TableRowData } from './type'; +import type { BaseTableCol, TableRowData } from './type'; +import type { FixedColumnLayoutState } from './utils/reorderFixedColumns'; export const BASE_TABLE_EVENTS = ['page-change', 'cell-click', 'scroll', 'scrollX', 'scrollY']; export const BASE_TABLE_ALL_EVENTS = ROW_LISTENERS.map((t) => `row-${t}`).concat(BASE_TABLE_EVENTS); @@ -47,7 +64,7 @@ const BaseTable = forwardRef((originalProps, ref) tableLayout, height, data, - columns, + columns: originColumns, style, headerAffixedTop, bordered, @@ -62,13 +79,39 @@ const BaseTable = forwardRef((originalProps, ref) const tableElmRef = useRef(null); const bottomContentRef = useRef(null); const [tableFootHeight, setTableFootHeight] = useState(0); + const [columns, setColumns] = useState(originColumns); + const pendingFixedRefreshRef = useRef(false); + const fixedLayoutSignatureRef = useRef(''); + const fixedReorderSignatureRef = useRef(''); + const scrollFixedLayoutRafRef = useRef(); + const pendingScrollLayoutRef = useRef | null>(null); + const lastScrollLeftRef = useRef(0); + const fixedLayoutActionsRef = useRef<{ + getThWidthList: () => Record; + refreshTable: () => void; + updateColumnFixedShadow: ( + target: HTMLElement | null, + extra?: { skipScrollLimit?: boolean }, + displayColumnsOverride?: BaseTableCol[], + ) => void; + syncFixedColumnBorder: (displayColumnsOverride?: BaseTableCol[]) => void; + tableContentRef: React.RefObject; + }>({ + getThWidthList: () => ({}), + refreshTable: () => undefined, + updateColumnFixedShadow: () => undefined, + syncFixedColumnBorder: () => undefined, + tableContentRef: { current: null }, + }); const allTableClasses = useClassName(); const { classPrefix, virtualScrollClasses, tableLayoutClasses, tableBaseClass, tableColFixedClasses } = allTableClasses; // 表格基础样式类 const { tableClasses, sizeClassNames, tableContentStyles, tableElementStyles } = useStyle(props); - const { isMultipleHeader, spansAndLeafNodes, thList } = useTableHeader({ columns: props.columns }); + const { isMultipleHeader, spansAndLeafNodes, thList } = useTableHeader({ + columns, + }); const finalColumns = useMemo( () => spansAndLeafNodes?.leafColumns || columns, [spansAndLeafNodes?.leafColumns, columns], @@ -115,17 +158,119 @@ const BaseTable = forwardRef((originalProps, ref) getThWidthList, updateThWidthList, updateTableAfterColumnResize, - } = useFixed(props, finalColumns, { + syncFixedColumnBorder, + } = useFixed(props, finalColumns, originColumns, { paginationAffixRef, horizontalScrollAffixRef, headerTopAffixRef, footerBottomAffixRef, }); + fixedLayoutActionsRef.current = { + getThWidthList, + refreshTable, + updateColumnFixedShadow, + syncFixedColumnBorder, + tableContentRef, + }; + + const refreshFixedOnLayoutChange = useCallback(() => { + const { + refreshTable: refresh, + updateColumnFixedShadow: updateShadow, + tableContentRef: contentRef, + } = fixedLayoutActionsRef.current; + refresh(); + updateShadow(contentRef.current, { skipScrollLimit: true }); + }, []); + + const resetFixedLayoutSignatures = useCallback(() => { + fixedReorderSignatureRef.current = ''; + fixedLayoutSignatureRef.current = ''; + }, []); + + const readFixedLayoutScrollMetrics = useCallback(() => { + const { getThWidthList, tableContentRef: contentRef } = fixedLayoutActionsRef.current; + return readFixedLayoutScrollMetricsFromElement(contentRef.current, originColumns, getThWidthList()); + }, [originColumns]); + + /** 解析左右 fixed 布局:签名未变则跳过;重排全量刷新,仅 border 变化走轻量 sync */ + const applyFixedColumnLayout = useCallback(() => { + const { getThWidthList, syncFixedColumnBorder: syncBorder } = fixedLayoutActionsRef.current; + const pendingLayout = pendingScrollLayoutRef.current; + pendingScrollLayoutRef.current = null; + const layout = + pendingLayout ?? resolveFixedColumnLayout(originColumns, readFixedLayoutScrollMetrics(), getThWidthList()); + + if (!layout.enabled) { + resetFixedLayoutSignatures(); + setColumns((prev) => (isSameDisplayColumns(prev, originColumns) ? prev : originColumns)); + const { updateColumnFixedShadow: updateShadow, tableContentRef: contentRef } = fixedLayoutActionsRef.current; + updateShadow(contentRef.current, { skipScrollLimit: true }); + return; + } + + const reorderSignature = `${layout.left.reorderSignature}::${layout.right.reorderSignature}`; + const reorderChanged = fixedReorderSignatureRef.current !== reorderSignature; + const layoutChanged = fixedLayoutSignatureRef.current !== layout.layoutSignature; + if (!reorderChanged && !layoutChanged) return; + + fixedReorderSignatureRef.current = reorderSignature; + fixedLayoutSignatureRef.current = layout.layoutSignature; + + setColumns((prev) => { + if (isSameDisplayColumns(prev, layout.displayColumns)) return prev; + pendingFixedRefreshRef.current = true; + return layout.displayColumns; + }); + + if (reorderChanged) { + pendingFixedRefreshRef.current = true; + } + if (reorderChanged || layoutChanged) { + syncBorder(layout.displayColumns); + } + }, [originColumns, resetFixedLayoutSignatures, readFixedLayoutScrollMetrics]); + + const scheduleScrollFixedColumnLayout = useCallback(() => { + if (scrollFixedLayoutRafRef.current != null) return; + scrollFixedLayoutRafRef.current = requestAnimationFrame(() => { + scrollFixedLayoutRafRef.current = undefined; + applyFixedColumnLayout(); + }); + }, [applyFixedColumnLayout]); + + useEffect( + () => () => { + if (scrollFixedLayoutRafRef.current != null) { + cancelAnimationFrame(scrollFixedLayoutRafRef.current); + } + }, + [], + ); + + useLayoutEffect(() => { + resetFixedLayoutSignatures(); + applyFixedColumnLayout(); + }, [originColumns, applyFixedColumnLayout, resetFixedLayoutSignatures]); + + // 列序 DOM 更新后再刷新 fixed 位置 + useLayoutEffect(() => { + if (!pendingFixedRefreshRef.current) return; + pendingFixedRefreshRef.current = false; + return scheduleAfterColumnDomUpdate(refreshFixedOnLayoutChange); + }, [columns, refreshFixedOnLayoutChange]); + const { onMount: onAffixHeaderMount } = useDomRefMount(affixHeaderRef); const { dataSource, innerPagination, isPaginateData, renderPagination } = usePagination(props, tableContentRef); + const onTableAfterColumnResize = useCallback(() => { + updateTableAfterColumnResize(); + resetFixedLayoutSignatures(); + applyFixedColumnLayout(); + }, [updateTableAfterColumnResize, resetFixedLayoutSignatures, applyFixedColumnLayout]); + // 列宽拖拽逻辑 const columnResizeParams = useColumnResize({ isWidthOverflow, @@ -134,7 +279,7 @@ const BaseTable = forwardRef((originalProps, ref) getThWidthList, updateThWidthList, setTableElmWidth, - updateTableAfterColumnResize, + updateTableAfterColumnResize: onTableAfterColumnResize, onColumnResizeChange: props.onColumnResizeChange, }); const { resizeLineRef, resizeLineStyle, setEffectColMap, updateTableWidthOnColumnChange } = columnResizeParams; @@ -183,7 +328,7 @@ const BaseTable = forwardRef((originalProps, ref) // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.data, dataSource, isPaginateData]); - const [lastLeafColumns, setLastLeafColumns] = useState(props.columns || []); + const [lastLeafColumns, setLastLeafColumns] = useState(columns || []); useEffect(() => { if (lastLeafColumns.map((t) => t.colKey).join() !== spansAndLeafNodes.leafColumns.map((t) => t.colKey).join()) { @@ -249,13 +394,36 @@ const BaseTable = forwardRef((originalProps, ref) const onInnerVirtualScroll = (e: React.WheelEvent) => { const target = e.target as HTMLElement; const top = target.scrollTop; - // 排除横向滚动触发的纵向虚拟滚动计算 + const left = target.scrollLeft; + if (lastScrollY !== top) { virtualConfig.isVirtualScroll && virtualConfig.handleScroll(); - } else { - lastScrollY = -1; - updateColumnFixedShadow(target); } + + // 横向滚动:rAF 合并 + 签名短路,避免每帧 setState 引发整表重渲染 + if (left !== lastScrollLeftRef.current) { + lastScrollLeftRef.current = left; + if (hasFixedColumnNeedReorder(originColumns)) { + const scrollMetrics = readFixedLayoutScrollMetricsFromElement(target, originColumns, getThWidthList()); + const layout = resolveFixedColumnLayout(originColumns, scrollMetrics, getThWidthList()); + const reorderSignature = `${layout.left.reorderSignature}::${layout.right.reorderSignature}`; + const reorderChanged = fixedReorderSignatureRef.current !== reorderSignature; + const layoutChanged = fixedLayoutSignatureRef.current !== layout.layoutSignature; + + updateColumnFixedShadow(target, undefined, layout.displayColumns); + + // 重排或 border 变化时同步标记,不等到 rAF,避免 shadow 已开但 border 列未更新的帧间闪烁 + if (layout.enabled && (reorderChanged || layoutChanged)) { + fixedLayoutActionsRef.current.syncFixedColumnBorder(layout.displayColumns); + } + + pendingScrollLayoutRef.current = layout; + scheduleScrollFixedColumnLayout(); + } else { + updateColumnFixedShadow(target); + } + } + lastScrollY = top; onHorizontalScroll(target); emitScrollEvent(e); @@ -391,7 +559,10 @@ const BaseTable = forwardRef((originalProps, ref) > {renderColGroup(true)} {showHeader && } @@ -452,15 +623,23 @@ const BaseTable = forwardRef((originalProps, ref) >
{renderColGroup(true)} ((originalProps, ref) {renderColGroup(false)} {useMemo(() => { if (!showHeader) return null; - return ; + return ( + + ); // eslint-disable-next-line - }, headUseMemoDependencies)} + }, headUseMemoDependencies)} {useMemo( () => ( ), // eslint-disable-next-line - [ + [ allTableClasses, tableBodyProps.ellipsisOverlayClassName, tableBodyProps.rowAndColFixedPosition, @@ -581,7 +767,7 @@ const BaseTable = forwardRef((originalProps, ref) > ), // eslint-disable-next-line - [ + [ isFixedHeader, rowAndColFixedPosition, spansAndLeafNodes, @@ -627,9 +813,9 @@ const BaseTable = forwardRef((originalProps, ref) const affixedHeaderContent = useMemo( renderAffixedHeader, // eslint-disable-next-line - [ + [ // eslint-disable-next-line - ...headUseMemoDependencies, + ...headUseMemoDependencies, showAffixHeader, tableWidth, tableElmWidth, diff --git a/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx b/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx new file mode 100644 index 0000000000..aed305ce6d --- /dev/null +++ b/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx @@ -0,0 +1,485 @@ +import React, { useMemo } from 'react'; +import { describe, expect, it } from 'vitest'; +import { act, fireEvent, render, waitFor } from '@test/utils'; + +import BaseTable from '../BaseTable'; + +import type { BaseTableCol, TableRowData } from '../type'; + +/** 与 demo 统一的 8 列定义顺序 */ +const baseColumns: BaseTableCol[] = [ + { colKey: 'id', title: 'ID', width: 100 }, + { colKey: 'name', title: 'Name', width: 120 }, + { colKey: 'email', title: 'Email', width: 180 }, + { colKey: 'dept', title: 'Dept', width: 120 }, + { colKey: 'address', title: 'Address', width: 220 }, + { colKey: 'city', title: 'City', width: 120 }, + { colKey: 'remark', title: 'Remark', width: 160 }, + { colKey: 'operation', title: 'Operation', width: 100 }, +]; + +const data: TableRowData[] = [ + { + id: 1, + name: '张三', + email: 'a@x.com', + dept: '研发', + city: '深圳', + address: '南山', + remark: 'A', + }, +]; + +function applyRightAddressFixed(cols: BaseTableCol[]): BaseTableCol[] { + return cols.map((col) => (col.colKey === 'address' ? { ...col, fixed: 'right' as const } : col)); +} + +function applyRightDisconnectedFixed(cols: BaseTableCol[]): BaseTableCol[] { + return cols.map((col) => { + if (col.colKey === 'address' || col.colKey === 'remark') { + return { ...col, fixed: 'right' as const }; + } + return col; + }); +} + +function applyLeftNameFixed(cols: BaseTableCol[]): BaseTableCol[] { + return cols.map((col) => (col.colKey === 'name' ? { ...col, fixed: 'left' as const } : col)); +} + +function applyLeftDisconnectedFixed(cols: BaseTableCol[]): BaseTableCol[] { + return cols.map((col) => { + if (col.colKey === 'name' || col.colKey === 'dept') { + return { ...col, fixed: 'left' as const }; + } + return col; + }); +} + +function mockTableScroll(content: HTMLElement, scrollLeft: number) { + Object.defineProperty(content, 'scrollLeft', { + configurable: true, + value: scrollLeft, + writable: true, + }); + Object.defineProperty(content, 'scrollWidth', { + configurable: true, + value: 1120, + writable: true, + }); + Object.defineProperty(content, 'clientWidth', { + configurable: true, + value: 720, + writable: true, + }); +} + +function getTableRoot(container: HTMLElement) { + return container.querySelector('.t-table') as HTMLElement; +} + +function getScrollContent(container: HTMLElement) { + return container.querySelector('.t-table__content') as HTMLElement; +} + +function getTh(container: HTMLElement, colKey: string) { + return container.querySelector(`th[data-colkey="${colKey}"]`); +} + +function getThColKeys(container: HTMLElement) { + return Array.from(container.querySelectorAll('thead th[data-colkey]')).map((th) => th.getAttribute('data-colkey')); +} + +async function flushFixedLayout() { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} + +function DemoLikeTable({ + fixedTarget, +}: { + fixedTarget: 'none' | 'leftName' | 'leftDisconnected' | 'rightAddress' | 'rightDisconnected'; +}) { + const columns = useMemo(() => { + if (fixedTarget === 'leftName') return applyLeftNameFixed(baseColumns); + if (fixedTarget === 'leftDisconnected') return applyLeftDisconnectedFixed(baseColumns); + if (fixedTarget === 'rightAddress') return applyRightAddressFixed(baseColumns); + if (fixedTarget === 'rightDisconnected') return applyRightDisconnectedFixed(baseColumns); + return baseColumns; + }, [fixedTarget]); + return ; +} + +describe('左固定 name 集成:重排与 border', () => { + it('scrollLeft=0:列序保持定义顺序,name 无 fixed-left-last', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + await waitFor(() => { + expect(getThColKeys(container)).toEqual([ + 'id', + 'name', + 'email', + 'dept', + 'address', + 'city', + 'remark', + 'operation', + ]); + expect(getTh(container, 'name')).not.toHaveClass('t-table__cell--fixed-left-last'); + expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-left'); + }); + }); + + it('scrollLeft=100:name 前置且有 fixed-left-last,容器有 scrollable-to-left', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + mockTableScroll(content, 100); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(getThColKeys(container)[0]).toBe('name'); + expect(getTh(container, 'name')).toHaveClass('t-table__cell--fixed-left-last'); + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-left'); + }); + }); + + it('scrollLeft=100 后回到 0:列序与 border 恢复', async () => { + const { container } = render(); + + const content = getScrollContent(container); + mockTableScroll(content, 100); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + mockTableScroll(content, 0); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(getThColKeys(container)[0]).toBe('id'); + expect(getTh(container, 'name')).not.toHaveClass('t-table__cell--fixed-left-last'); + }); + }); +}); + +describe('左固定不相连 集成:border 交接', () => { + it('scrollLeft=100:border 在 name', async () => { + const { container } = render(); + + const content = getScrollContent(container); + mockTableScroll(content, 100); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(getTh(container, 'name')).toHaveClass('t-table__cell--fixed-left-last'); + expect(getTh(container, 'dept')).not.toHaveClass('t-table__cell--fixed-left-last'); + }); + }); + + it('scrollLeft=300:border 交接至 dept', async () => { + const { container } = render(); + + const content = getScrollContent(container); + mockTableScroll(content, 300); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(getTh(container, 'dept')).toHaveClass('t-table__cell--fixed-left-last'); + expect(getTh(container, 'name')).not.toHaveClass('t-table__cell--fixed-left-last'); + }); + }); +}); + +describe('左固定模式切换(复现 demo Radio 切换)', () => { + it('从 none 切到 leftName 后 scroll=100 应触发重排', async () => { + const { container, rerender } = render(); + await flushFixedLayout(); + + rerender(); + await flushFixedLayout(); + + const content = getScrollContent(container); + mockTableScroll(content, 100); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(getThColKeys(container)[0]).toBe('name'); + expect(getTh(container, 'name')).toHaveClass('t-table__cell--fixed-left-last'); + }); + }); +}); + +describe('右固定 address 集成:border 与 shadow 同步', () => { + it('scrollLeft=0:address 有 fixed-right-first 且容器有 scrollable-to-right', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('scrollLeft=20:address 脱离右边界,无 fixed-right-first、无 scrollable-to-right', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + mockTableScroll(content, 20); + + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('scrollLeft=20 后回到 0:border 与 shadow 恢复', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 20); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + mockTableScroll(content, 0); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('滚到最右端:border 与 shadow 均应清除', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 20); + + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + mockTableScroll(content, 400); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); +}); + +describe('右不相连 address+remark 集成:border 与重排', () => { + it('scrollLeft=50 address 已重排、remark 贴边:border 在 remark', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 50); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'remark')).toHaveClass('t-table__cell--fixed-right-first'); + expect(getTh(container, 'address')).not.toHaveClass('t-table__cell--fixed-right'); + }); + }); + + it('scrollLeft=0 address 有 border(address+remark 两列右 fixed)', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).toHaveClass('t-table__cell--fixed-right-first'); + expect(getTh(container, 'remark')).toHaveClass('t-table__cell--fixed-right'); + }); + }); + + it('scrollLeft=250:remark 有 border', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + mockTableScroll(content, 250); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'remark')).toHaveClass('t-table__cell--fixed-right-first'); + expect(getTh(container, 'address')).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('scrollLeft=300 remark 达阈值:border 清除', async () => { + const { container } = render(); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 250); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + mockTableScroll(content, 300); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'remark')).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('scrollLeft=20 address 重排后 border 交接至 remark', async () => { + const { container } = render(); + + const content = getScrollContent(container); + mockTableScroll(content, 10); + await flushFixedLayout(); + + mockTableScroll(content, 20); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(getTh(container, 'remark')).toHaveClass('t-table__cell--fixed-right-first'); + expect(getTh(container, 'address')).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('scrollLeft=300 后回到 0:border 回到 address', async () => { + const { container } = render(); + + const content = getScrollContent(container); + mockTableScroll(content, 300); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + mockTableScroll(content, 0); + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(getTh(container, 'address')).toHaveClass('t-table__cell--fixed-right-first'); + expect(getTh(container, 'remark')).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); +}); + +describe('右固定 address 模式切换(复现 demo Radio 切换)', () => { + it('从「不固定」切到「右固定 address」后 scroll=0 应显示 border 加粗', async () => { + const { container, rerender } = render(); + await flushFixedLayout(); + + rerender(); + await flushFixedLayout(); + + const tableRoot = getTableRoot(container); + const content = getScrollContent(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('模式切换后滚到 20 应清除 border 加粗', async () => { + const { container, rerender } = render(); + await flushFixedLayout(); + + rerender(); + await flushFixedLayout(); + + const tableRoot = getTableRoot(container); + const content = getScrollContent(container); + mockTableScroll(content, 20); + + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); +}); diff --git a/packages/components/table/__tests__/fixed-column-reorder.test.ts b/packages/components/table/__tests__/fixed-column-reorder.test.ts new file mode 100644 index 0000000000..ff182e48b8 --- /dev/null +++ b/packages/components/table/__tests__/fixed-column-reorder.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; + +import { + getDeferredRightFixedStickyColKeys, + getRightFixedBorderBoundaryColKey, + getRightFixedReorderTriggerEntries, + getTriggeredRightFixedColKeys, + reorderColumnsForRightFixedPartial, + resolveColumnsForRightFixed, + shouldShowRightFixedColumnShadow, +} from '../utils/reorderFixedColumns'; + +import type { BaseTableCol } from '../type'; + +/** 与 demo 统一的 8 列定义顺序(address—city—remark 镜像左 name—email—dept) */ +const DEMO_COLUMN_ORDER = ['id', 'name', 'email', 'dept', 'address', 'city', 'remark', 'operation'] as const; + +function buildDemoColumns( + fixed: Partial>, +): BaseTableCol[] { + const widths: Record = { + id: 100, + name: 120, + email: 180, + dept: 120, + address: 220, + city: 120, + remark: 160, + operation: 100, + }; + return DEMO_COLUMN_ORDER.map((colKey) => ({ + colKey, + width: widths[colKey], + ...(fixed[colKey] ? { fixed: fixed[colKey] } : {}), + })); +} + +/** 右不相连:address + remark,city 夹在中间(镜像左 name+dept) */ +const siteRightDisconnectedColumns = buildDemoColumns({ + address: 'right', + remark: 'right', +}); + +/** 右:固定 address */ +const rightAddressColumns = buildDemoColumns({ address: 'right' }); + +/** 旧版三列 operation 右固定(保留重排用例) */ +const rightDisconnectedDemoColumns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'address', width: 220, fixed: 'right' }, + { colKey: 'city', width: 120 }, + { colKey: 'remark', width: 160, fixed: 'right' }, + { colKey: 'email', width: 180 }, + { colKey: 'operation', width: 100, fixed: 'right' }, +]; + +const DEMO_MAX_SCROLL_LEFT = 560; +const SITE_MAX_SCROLL_LEFT = 400; +const RIGHT_ADDRESS_MAX_SCROLL_LEFT = 400; +const ADDRESS_WIDTH_AFTER = 380; +const REMARK_WIDTH_AFTER = 100; + +const demoScroll = (scrollLeft: number) => ({ + scrollLeft, + maxScrollLeft: DEMO_MAX_SCROLL_LEFT, +}); +const siteScroll = (scrollLeft: number) => ({ + scrollLeft, + maxScrollLeft: SITE_MAX_SCROLL_LEFT, +}); +const rightAddressScroll = (scrollLeft: number) => ({ + scrollLeft, + maxScrollLeft: RIGHT_ADDRESS_MAX_SCROLL_LEFT, +}); + +describe('fixed-column-reorder demo:右不相连 partial 重排', () => { + it('address 与 remark 均触发时保持定义顺序且 operation 在最末', () => { + expect( + reorderColumnsForRightFixedPartial(rightDisconnectedDemoColumns, new Set(['address', 'remark'])).map( + (col) => col.colKey, + ), + ).toEqual(['id', 'city', 'email', 'address', 'remark', 'operation']); + }); + + it('仅 remark 触发时 address 留在原位置', () => { + expect( + reorderColumnsForRightFixedPartial(rightDisconnectedDemoColumns, new Set(['remark'])).map((col) => col.colKey), + ).toEqual(['id', 'address', 'city', 'email', 'remark', 'operation']); + }); +}); + +describe('fixed-column-reorder demo:右不相连 deferred sticky', () => { + it('scroll=0 两列均 sticky', () => { + expect(getDeferredRightFixedStickyColKeys(siteRightDisconnectedColumns, siteScroll(0))).toEqual(new Set()); + }); + + it('address 触发重排后取消 sticky', () => { + expect(getDeferredRightFixedStickyColKeys(siteRightDisconnectedColumns, siteScroll(20))).toEqual( + new Set(['address']), + ); + }); +}); + +describe('fixed-column-reorder demo:右重排阈值(统一列序)', () => { + it('右不相连 address 与 remark 各有独立 widthAfter', () => { + expect(getRightFixedReorderTriggerEntries(siteRightDisconnectedColumns)).toEqual([ + { colKey: 'address', threshold: 0, widthAfter: ADDRESS_WIDTH_AFTER }, + { colKey: 'remark', threshold: 0, widthAfter: REMARK_WIDTH_AFTER }, + ]); + }); +}); + +describe('fixed-column-reorder demo:右 border 跟贴边栈', () => { + it('右不相连 remark 重排后 border 清除(scroll=200)', () => { + const display = reorderColumnsForRightFixedPartial(rightDisconnectedDemoColumns, new Set(['remark'])); + expect( + getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, { + scrollLeft: 200, + maxScrollLeft: 400, + }), + ).toBeUndefined(); + }); + + it('address 与 remark 均重排时 border 清除', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedDemoColumns, demoScroll(420)); + expect(getTriggeredRightFixedColKeys(rightDisconnectedDemoColumns, demoScroll(420))).toEqual(['address', 'remark']); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(420))).toBeUndefined(); + }); + + it('滚到最右端无 border', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedDemoColumns, demoScroll(560)); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(560))).toBeUndefined(); + }); +}); + +describe('fixed-column-reorder demo:站点列宽右不相连', () => { + it('scroll=50 address 已重排、remark 贴右:border 在 remark', () => { + expect( + getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(50)), + ).toBe('remark'); + }); + + it('scroll=0 address 贴右有 border(address+remark 两列 fixed)', () => { + expect( + getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(0)), + ).toBe('address'); + }); + + it('scroll=20 address 达阈值后 border 交接至 remark', () => { + expect( + getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(20)), + ).toBe('remark'); + }); + + it('scroll=250 remark 贴右未达阈值:border 在 remark', () => { + expect( + getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(250)), + ).toBe('remark'); + }); + + it('scroll=300 remark 达重排阈值:border 清除', () => { + const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(300)); + expect(getTriggeredRightFixedColKeys(siteRightDisconnectedColumns, siteScroll(300))).toEqual(['address', 'remark']); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(300))).toBeUndefined(); + }); + + it('scroll=300 后回到 10:border 回到 address', () => { + expect( + getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(10)), + ).toBe('address'); + }); + + it('滚到最右端无 border', () => { + const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(400)); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(400))).toBeUndefined(); + }); +}); + +describe('fixed-column-reorder demo:右固定 address', () => { + it('scroll=20 达重排阈值:列重排触发,border 与 shadow 清除', () => { + const display = resolveColumnsForRightFixed(rightAddressColumns, rightAddressScroll(20)); + expect(getTriggeredRightFixedColKeys(rightAddressColumns, rightAddressScroll(20))).toEqual(['address']); + expect(getRightFixedBorderBoundaryColKey(rightAddressColumns, display, rightAddressScroll(20))).toBeUndefined(); + expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(20), {}, display)).toBe(false); + }); + + it('scroll=19 未达重排阈值:仍有 border', () => { + expect(getRightFixedBorderBoundaryColKey(rightAddressColumns, rightAddressColumns, rightAddressScroll(19))).toBe( + 'address', + ); + }); + + it('scroll=0 有 border、有阴影', () => { + expect(getRightFixedBorderBoundaryColKey(rightAddressColumns, rightAddressColumns, rightAddressScroll(0))).toBe( + 'address', + ); + expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(0))).toBe(true); + }); + + it('滚到最右端无 border、无阴影', () => { + expect( + getRightFixedBorderBoundaryColKey(rightAddressColumns, rightAddressColumns, rightAddressScroll(400)), + ).toBeUndefined(); + expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(400))).toBe(false); + }); +}); + +describe('fixed-column-reorder demo:右 border 与 shadow 同步', () => { + it('scroll=250 贴边阶段:border 与 shadow 同时激活', () => { + const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(250)); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(250))).toBe('remark'); + expect(shouldShowRightFixedColumnShadow(siteRightDisconnectedColumns, siteScroll(250), {}, display)).toBe(true); + }); +}); diff --git a/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts b/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts new file mode 100644 index 0000000000..dbd4c41dac --- /dev/null +++ b/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { markFixedColumnBoundaries } from '../utils/markFixedColumnBoundaries'; + +import type { FixedColumnInfo } from '../interface'; +import type { FixedColumnLayoutState } from '../utils/reorderFixedColumns'; + +function createLevelNodes(colKeys: string[], fixedMap: Record): FixedColumnInfo[][] { + return [ + colKeys.map((colKey) => ({ + col: { colKey, fixed: fixedMap[colKey] }, + lastLeftFixedCol: false, + firstRightFixedCol: false, + })), + ]; +} + +describe('markFixedColumnBoundaries', () => { + it('右侧内置重排 sticky 激活时按 borderBoundaryColKey 标记,不回落默认规则', () => { + const levelNodes = createLevelNodes(['id', 'city', 'email', 'remark', 'operation'], { + remark: 'right', + operation: 'right', + }); + const layout: FixedColumnLayoutState = { + enabled: true, + displayColumns: [], + left: { + enabled: false, + reorderTriggeredKeys: [], + showShadow: false, + reorderSignature: '', + sideLayoutSignature: '', + }, + right: { + enabled: true, + reorderTriggeredKeys: ['remark'], + borderBoundaryColKey: 'remark', + showShadow: false, + reorderSignature: 'remark', + sideLayoutSignature: 'remark|remark', + }, + layoutSignature: '::remark|remark', + }; + + markFixedColumnBoundaries(levelNodes, layout); + + expect(levelNodes[0][3].firstRightFixedCol).toBe(true); + expect(levelNodes[0][4].firstRightFixedCol).toBe(false); + }); + + it('右侧内置重排启用且 borderBoundaryColKey 为空时,不标记默认 border', () => { + const levelNodes = createLevelNodes(['id', 'address', 'city', 'remark', 'operation'], { + address: 'right', + remark: 'right', + operation: 'right', + }); + const layout: FixedColumnLayoutState = { + enabled: true, + displayColumns: [], + left: { + enabled: false, + reorderTriggeredKeys: [], + showShadow: false, + reorderSignature: '', + sideLayoutSignature: '', + }, + right: { + enabled: true, + reorderTriggeredKeys: [], + borderBoundaryColKey: undefined, + showShadow: false, + reorderSignature: '', + sideLayoutSignature: '', + }, + layoutSignature: '::', + }; + + markFixedColumnBoundaries(levelNodes, layout); + + expect(levelNodes[0][1].firstRightFixedCol).toBe(false); + expect(levelNodes[0][3].firstRightFixedCol).toBe(false); + expect(levelNodes[0][4].firstRightFixedCol).toBe(false); + }); + + it('左侧内置重排启用时,右侧连续固定列仍按默认规则标记 border', () => { + const levelNodes = createLevelNodes(['id', 'name', 'email', 'remark', 'operation'], { + remark: 'right', + operation: 'right', + }); + const layout: FixedColumnLayoutState = { + enabled: true, + displayColumns: [], + left: { + enabled: true, + reorderTriggeredKeys: ['name'], + borderBoundaryColKey: 'name', + showShadow: true, + reorderSignature: 'name', + sideLayoutSignature: 'name|name', + }, + right: { + enabled: false, + reorderTriggeredKeys: [], + borderBoundaryColKey: undefined, + showShadow: false, + reorderSignature: '', + sideLayoutSignature: '', + }, + layoutSignature: 'name|name::', + }; + + markFixedColumnBoundaries(levelNodes, layout); + + expect(levelNodes[0][1].lastLeftFixedCol).toBe(true); + expect(levelNodes[0][3].firstRightFixedCol).toBe(true); + expect(levelNodes[0][4].firstRightFixedCol).toBe(false); + }); + + it('标准右固定(无内置重排)在 showShadow=false 时仍标记 fixed-right-first', () => { + const levelNodes = createLevelNodes(['id', 'name', 'email', 'operation'], { + operation: 'right', + }); + const layout: FixedColumnLayoutState = { + enabled: false, + displayColumns: [], + left: { + enabled: false, + reorderTriggeredKeys: [], + showShadow: false, + reorderSignature: '', + sideLayoutSignature: '', + }, + right: { + enabled: false, + reorderTriggeredKeys: [], + borderBoundaryColKey: undefined, + showShadow: false, + reorderSignature: '', + sideLayoutSignature: '', + }, + layoutSignature: '::', + }; + + markFixedColumnBoundaries(levelNodes, layout); + + expect(levelNodes[0][3].firstRightFixedCol).toBe(true); + }); +}); diff --git a/packages/components/table/__tests__/reorderFixedColumns.test.ts b/packages/components/table/__tests__/reorderFixedColumns.test.ts new file mode 100644 index 0000000000..84c642dcbb --- /dev/null +++ b/packages/components/table/__tests__/reorderFixedColumns.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeRightFixedOffsets, + getLeftFixedBorderBoundaryColKey, + getLeftFixedReorderTriggerEntries, + getLeftFixedReorderTriggerScrollLeft, + getRightFixedBorderBoundaryColKey, + getRightFixedReorderTriggerEntries, + getScrollFromRight, + getTriggeredLeftFixedColKeys, + getTriggeredRightFixedColKeys, + hasLeftFixedColumnNeedReorder, + hasRightFixedColumnNeedReorder, + isLeftFixedReorderTriggered, + isSameDisplayColumns, + readFixedLayoutScrollMetricsFromElement, + reorderColumnsForLeftFixed, + reorderColumnsForLeftFixedPartial, + reorderColumnsForRightFixed, + reorderColumnsForRightFixedPartial, + resolveColumnsForLeftFixed, + resolveColumnsForRightFixed, + resolveFixedColumnLayout, + resolveLeftFixedLayout, + shouldShowLeftFixedColumnShadow, + shouldShowRightFixedColumnShadow, +} from '../utils/reorderFixedColumns'; + +import type { BaseTableCol } from '../type'; + +const disconnectedColumns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120, fixed: 'left' }, + { colKey: 'email', width: 180 }, + { colKey: 'dept', width: 120, fixed: 'left' }, +]; + +const rightDisconnectedColumns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'email', width: 180 }, + { colKey: 'remark', width: 120, fixed: 'right' }, + { colKey: 'city', width: 120 }, + { colKey: 'operation', width: 100, fixed: 'right' }, +]; + +const RIGHT_MAX_SCROLL_LEFT = 300; + +const rightScroll = (scrollLeft: number) => ({ + scrollLeft, + maxScrollLeft: RIGHT_MAX_SCROLL_LEFT, +}); + +describe('readFixedLayoutScrollMetricsFromElement', () => { + it('scrollWidth 未更新时用列宽总和兜底 maxScrollLeft', () => { + const columns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120, fixed: 'left' }, + { colKey: 'email', width: 180 }, + { colKey: 'dept', width: 120 }, + { colKey: 'address', width: 220 }, + { colKey: 'city', width: 120 }, + { colKey: 'remark', width: 160 }, + { colKey: 'operation', width: 100 }, + ]; + const el = { + scrollLeft: 50, + scrollWidth: 720, + clientWidth: 720, + } as HTMLElement; + + expect(readFixedLayoutScrollMetricsFromElement(el, columns, {})).toEqual({ + scrollLeft: 50, + maxScrollLeft: 400, + }); + }); + + it('DOM 已有有效 maxScrollLeft 时直接采用', () => { + const columns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120, fixed: 'left' }, + ]; + const el = { + scrollLeft: 10, + scrollWidth: 1120, + clientWidth: 720, + } as HTMLElement; + + expect(readFixedLayoutScrollMetricsFromElement(el, columns)).toEqual({ + scrollLeft: 10, + maxScrollLeft: 400, + }); + }); +}); + +describe('reorderColumnsForLeftFixed', () => { + it('非首列左固定时,将 fixed 列移到功能列之后的最左侧', () => { + const columns: BaseTableCol[] = [ + { colKey: 'id', title: 'ID', width: 80 }, + { colKey: 'name', title: '姓名', width: 120, fixed: 'left' }, + { colKey: 'email', title: '邮箱', width: 200 }, + ]; + const result = reorderColumnsForLeftFixed(columns); + expect(result.map((col) => col.colKey)).toEqual(['name', 'id', 'email']); + }); + + it('保留展开列在最前,name 紧随其后左固定', () => { + const columns: BaseTableCol[] = [ + { colKey: '__EXPAND_ROW_ICON_COLUMN__', width: 46, fixed: 'left' }, + { colKey: 'id', title: 'ID', width: 80 }, + { colKey: 'name', title: '姓名', width: 120, fixed: 'left' }, + ]; + const result = reorderColumnsForLeftFixed(columns); + expect(result.map((col) => col.colKey)).toEqual(['__EXPAND_ROW_ICON_COLUMN__', 'name', 'id']); + }); + + it('多个非连续 left fixed 全量前置', () => { + const result = reorderColumnsForLeftFixed(disconnectedColumns); + expect(result.map((col) => col.colKey)).toEqual(['name', 'dept', 'id', 'email']); + }); + + it('从左连续固定的列顺序不变', () => { + const columns: BaseTableCol[] = [{ colKey: 'a', fixed: 'left' }, { colKey: 'b', fixed: 'left' }, { colKey: 'c' }]; + expect(reorderColumnsForLeftFixed(columns)).toBe(columns); + }); + + it('无左固定列时不重排', () => { + const columns: BaseTableCol[] = [{ colKey: 'id' }, { colKey: 'name' }]; + expect(reorderColumnsForLeftFixed(columns)).toBe(columns); + }); + + it('hasLeftFixedColumnNeedReorder 识别非首列左固定', () => { + expect(hasLeftFixedColumnNeedReorder([{ colKey: 'id' }, { colKey: 'name', fixed: 'left' }])).toBe(true); + expect(hasLeftFixedColumnNeedReorder([{ colKey: 'name', fixed: 'left' }, { colKey: 'id' }])).toBe(false); + }); +}); + +describe('reorderColumnsForLeftFixedPartial', () => { + it('仅 name 触发时只前置 name,dept 仍留在原位置', () => { + const result = reorderColumnsForLeftFixedPartial(disconnectedColumns, new Set(['name'])); + expect(result.map((col) => col.colKey)).toEqual(['name', 'id', 'email', 'dept']); + }); + + it('name 与 dept 均触发时按定义顺序一并前置', () => { + const result = reorderColumnsForLeftFixedPartial(disconnectedColumns, new Set(['name', 'dept'])); + expect(result.map((col) => col.colKey)).toEqual(['name', 'dept', 'id', 'email']); + }); +}); + +describe('getLeftFixedReorderTriggerEntries', () => { + it('不相连多列为每列独立计算贴左阈值', () => { + expect(getLeftFixedReorderTriggerEntries(disconnectedColumns)).toEqual([ + { colKey: 'name', threshold: 100 }, + { colKey: 'dept', threshold: 400 }, + ]); + }); +}); + +describe('isLeftFixedReorderTriggered', () => { + const columnsWithNameFixed: BaseTableCol[] = [ + { colKey: 'id', title: 'ID', width: 100 }, + { colKey: 'name', title: '姓名', width: 120, fixed: 'left' }, + { colKey: 'email', title: '邮箱', width: 200 }, + ]; + + it('计算首个 left fixed 贴左前的 scrollLeft 阈值', () => { + expect(getLeftFixedReorderTriggerScrollLeft(columnsWithNameFixed)).toBe(100); + expect(getLeftFixedReorderTriggerScrollLeft(columnsWithNameFixed, { id: 90 })).toBe(90); + }); + + it('scrollLeft 未达阈值时不触发', () => { + expect(isLeftFixedReorderTriggered(columnsWithNameFixed, 50)).toBe(false); + expect(resolveColumnsForLeftFixed(columnsWithNameFixed, 50)).toBe(columnsWithNameFixed); + }); + + it('scrollLeft 达到阈值时触发', () => { + expect(isLeftFixedReorderTriggered(columnsWithNameFixed, 100)).toBe(true); + expect(resolveColumnsForLeftFixed(columnsWithNameFixed, 100).map((col) => col.colKey)).toEqual([ + 'name', + 'id', + 'email', + ]); + }); + + it('不相连多列:name 与 dept 分阶段触发', () => { + expect(getTriggeredLeftFixedColKeys(disconnectedColumns, 150)).toEqual(['name']); + expect(resolveColumnsForLeftFixed(disconnectedColumns, 150).map((col) => col.colKey)).toEqual([ + 'name', + 'id', + 'email', + 'dept', + ]); + + expect(getTriggeredLeftFixedColKeys(disconnectedColumns, 400)).toEqual(['name', 'dept']); + expect(resolveColumnsForLeftFixed(disconnectedColumns, 400).map((col) => col.colKey)).toEqual([ + 'name', + 'dept', + 'id', + 'email', + ]); + }); + + it('滚回 dept 阈值以下时 dept 还原、name 仍保持前置', () => { + expect(getTriggeredLeftFixedColKeys(disconnectedColumns, 350)).toEqual(['name']); + expect(resolveColumnsForLeftFixed(disconnectedColumns, 350).map((col) => col.colKey)).toEqual([ + 'name', + 'id', + 'email', + 'dept', + ]); + }); + + it('无 left fixed 时即使滚动也不触发', () => { + const columns: BaseTableCol[] = [{ colKey: 'id' }, { colKey: 'name' }]; + expect(isLeftFixedReorderTriggered(columns, 200)).toBe(false); + }); +}); + +describe('shouldShowLeftFixedColumnShadow', () => { + const columnsWithNameFixed: BaseTableCol[] = [ + { colKey: 'id', title: 'ID', width: 100 }, + { colKey: 'name', title: '姓名', width: 120, fixed: 'left' }, + { colKey: 'email', title: '邮箱', width: 200 }, + ]; + + it('非首列 left fixed 时,未达重排阈值不显示左阴影', () => { + expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 50)).toBe(false); + expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 99)).toBe(false); + }); + + it('达重排阈值后显示左阴影', () => { + expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 100)).toBe(true); + }); + + it('scrollLeft 为 0 时不显示左阴影', () => { + expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 0)).toBe(false); + }); + + it('普通连续 left fixed 仍按 scrollLeft > 0 显示左阴影', () => { + const columns: BaseTableCol[] = [{ colKey: 'name', fixed: 'left' }, { colKey: 'id' }]; + expect(shouldShowLeftFixedColumnShadow(columns, 1)).toBe(true); + }); +}); + +describe('getLeftFixedBorderBoundaryColKey', () => { + it('name 重排后、dept sticky 未激活时 border 只在 name', () => { + const display = resolveColumnsForLeftFixed(disconnectedColumns, 150); + expect(getLeftFixedBorderBoundaryColKey(disconnectedColumns, display, 150)).toBe('name'); + }); + + it('dept sticky 激活后(280px)即显示 border,不必等到重排阈值 400px', () => { + const display = resolveColumnsForLeftFixed(disconnectedColumns, 300); + expect(getLeftFixedBorderBoundaryColKey(disconnectedColumns, display, 300)).toBe('dept'); + expect(getTriggeredLeftFixedColKeys(disconnectedColumns, 300)).toEqual(['name']); + }); + + it('dept 重排后 border 仍在 dept', () => { + const display = resolveColumnsForLeftFixed(disconnectedColumns, 400); + expect(getLeftFixedBorderBoundaryColKey(disconnectedColumns, display, 400)).toBe('dept'); + }); + + it('未进入重排阶段时返回 undefined,沿用默认 border 逻辑', () => { + expect(getLeftFixedBorderBoundaryColKey(disconnectedColumns, disconnectedColumns, 50)).toBeUndefined(); + }); +}); + +describe('resolveLeftFixedLayout', () => { + it('统一返回列序、border、阴影与签名', () => { + const layout = resolveLeftFixedLayout(disconnectedColumns, 300); + expect(layout.enabled).toBe(true); + expect(layout.reorderTriggeredKeys).toEqual(['name']); + expect(layout.displayColumns.map((col) => col.colKey)).toEqual(['name', 'id', 'email', 'dept']); + expect(layout.borderBoundaryColKey).toBe('dept'); + expect(layout.showLeftShadow).toBe(true); + expect(layout.reorderSignature).toBe('name'); + expect(layout.layoutSignature).toBe('dept|name'); + }); + + it('无内置行为时 enabled 为 false', () => { + const columns: BaseTableCol[] = [{ colKey: 'name', fixed: 'left' }, { colKey: 'id' }]; + const layout = resolveLeftFixedLayout(columns, 10); + expect(layout.enabled).toBe(false); + expect(layout.showLeftShadow).toBe(true); + }); +}); + +describe('isSameDisplayColumns', () => { + it('列序相同但 fixed 配置变化时判定为不同', () => { + const prev: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120, fixed: 'left' }, + ]; + const next: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120 }, + ]; + expect(isSameDisplayColumns(prev, next)).toBe(false); + }); +}); + +describe('reorderColumnsForRightFixed', () => { + it('非末列右固定时,将 fixed 列移到可滚动区之后的最右侧', () => { + const columns: BaseTableCol[] = [ + { colKey: 'id', width: 80 }, + { colKey: 'email', width: 200 }, + { colKey: 'remark', width: 120, fixed: 'right' }, + ]; + expect(reorderColumnsForRightFixed(columns).map((col) => col.colKey)).toEqual(['id', 'email', 'remark']); + }); + + it('从右连续固定的列顺序不变', () => { + const columns: BaseTableCol[] = [{ colKey: 'a' }, { colKey: 'b', fixed: 'right' }, { colKey: 'c', fixed: 'right' }]; + expect(reorderColumnsForRightFixed(columns)).toBe(columns); + }); + + it('hasRightFixedColumnNeedReorder 识别非末列右固定', () => { + expect( + hasRightFixedColumnNeedReorder([ + { colKey: 'id' }, + { colKey: 'remark', fixed: 'right' }, + { colKey: 'email' }, + { colKey: 'op', fixed: 'right' }, + ]), + ).toBe(true); + expect(hasRightFixedColumnNeedReorder([{ colKey: 'id' }, { colKey: 'op', fixed: 'right' }])).toBe(false); + expect( + hasRightFixedColumnNeedReorder([ + { colKey: 'b', fixed: 'right' }, + { colKey: 'c', fixed: 'right' }, + ]), + ).toBe(false); + }); +}); + +describe('reorderColumnsForRightFixedPartial', () => { + it('仅 remark 触发时只后置 remark,operation 仍留在最末', () => { + expect( + reorderColumnsForRightFixedPartial(rightDisconnectedColumns, new Set(['remark'])).map((col) => col.colKey), + ).toEqual(['id', 'email', 'city', 'remark', 'operation']); + }); + + it('remark 与 operation 均触发时按定义顺序一并后置', () => { + expect( + reorderColumnsForRightFixedPartial(rightDisconnectedColumns, new Set(['remark', 'operation'])).map( + (col) => col.colKey, + ), + ).toEqual(['id', 'email', 'city', 'remark', 'operation']); + }); +}); + +describe('getRightFixedReorderTriggerEntries', () => { + it('不相连多列为每列独立计算贴右阈值', () => { + expect(getRightFixedReorderTriggerEntries(rightDisconnectedColumns)).toEqual([ + { colKey: 'remark', threshold: 0, widthAfter: 220 }, + ]); + }); +}); + +describe('resolveColumnsForRightFixed', () => { + it('scrollLeft 未达阈值时不重排', () => { + expect(resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(50))).toBe(rightDisconnectedColumns); + }); + + it('scrollLeft 达到阈值时 remark 后置', () => { + expect(resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(80)).map((col) => col.colKey)).toEqual([ + 'id', + 'email', + 'city', + 'remark', + 'operation', + ]); + }); + + it('滚回阈值以下时 remark 还原', () => { + expect(resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(70)).map((col) => col.colKey)).toEqual([ + 'id', + 'email', + 'remark', + 'city', + 'operation', + ]); + }); +}); + +describe('computeRightFixedOffsets', () => { + it('单列表 address:right 偏移为 0', () => { + const columns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'address', width: 220, fixed: 'right' }, + { colKey: 'remark', width: 160 }, + ]; + expect(computeRightFixedOffsets(columns).get('address')).toBe(0); + }); + + it('address + remark 贴边栈:address 偏移为 remark 宽度', () => { + const columns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'address', width: 220, fixed: 'right' }, + { colKey: 'remark', width: 160, fixed: 'right' }, + ]; + expect(computeRightFixedOffsets(columns).get('remark')).toBe(0); + expect(computeRightFixedOffsets(columns).get('address')).toBe(160); + }); +}); + +describe('getScrollFromRight', () => { + it('scrollLeft=0 时等于 maxScrollLeft', () => { + expect(getScrollFromRight({ scrollLeft: 0, maxScrollLeft: 400 })).toBe(400); + }); + + it('滚到最右时等于 0', () => { + expect(getScrollFromRight({ scrollLeft: 400, maxScrollLeft: 400 })).toBe(0); + }); +}); + +describe('getRightFixedBorderBoundaryColKey', () => { + it('scroll=50 remark 贴边未达阈值:border 在 remark(remark+operation 两列 fixed)', () => { + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50))).toBe( + 'remark', + ); + }); + + it('scroll=200 remark 达重排阈值:border 清除', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(200)); + expect(getTriggeredRightFixedColKeys(rightDisconnectedColumns, rightScroll(200))).toEqual(['remark']); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(200))).toBeUndefined(); + }); + + it('scroll=90 remark 达重排阈值:border 清除', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(90)); + expect(getTriggeredRightFixedColKeys(rightDisconnectedColumns, rightScroll(90))).toEqual(['remark']); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(90))).toBeUndefined(); + }); + + it('scroll=79 remark 贴边未达阈值:border 在 remark', () => { + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(79))).toBe( + 'remark', + ); + }); + + it('scroll=50 未达重排阈值:border 在 remark', () => { + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50))).toBe( + 'remark', + ); + }); +}); + +describe('fixed border 左 sticky / 右 scrollFromRight', () => { + it('未重排 scroll=50:左无 border;右 remark 贴边', () => { + expect(getLeftFixedBorderBoundaryColKey(disconnectedColumns, disconnectedColumns, 50)).toBeUndefined(); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50))).toBe( + 'remark', + ); + }); + + it('首列重排 scroll=150:左 name sticky;右 remark 已重排无 border', () => { + expect( + getLeftFixedBorderBoundaryColKey(disconnectedColumns, resolveColumnsForLeftFixed(disconnectedColumns, 150), 150), + ).toBe('name'); + expect( + getRightFixedBorderBoundaryColKey( + rightDisconnectedColumns, + resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(150)), + rightScroll(150), + ), + ).toBeUndefined(); + }); +}); + +describe('shouldShowRightFixedColumnShadow', () => { + const columnsWithRemarkFixed: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'email', width: 180 }, + { colKey: 'remark', width: 120, fixed: 'right' }, + { colKey: 'city', width: 120 }, + { colKey: 'operation', width: 100, fixed: 'right' }, + ]; + + it('未达重排阈值时 remark 贴边有 border、有阴影', () => { + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(50))).toBe(true); + expect(getRightFixedBorderBoundaryColKey(columnsWithRemarkFixed, columnsWithRemarkFixed, rightScroll(50))).toBe( + 'remark', + ); + }); + + it('scrollLeft 为 0 时 remark 贴边:有 border、有 shadow', () => { + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(0))).toBe(true); + expect(getRightFixedBorderBoundaryColKey(columnsWithRemarkFixed, columnsWithRemarkFixed, rightScroll(0))).toBe( + 'remark', + ); + }); + + it('达重排阈值后 border 与 shadow 清除(remark @80,scroll=90)', () => { + const displayAt90 = resolveColumnsForRightFixed(columnsWithRemarkFixed, rightScroll(90)); + expect(getRightFixedBorderBoundaryColKey(columnsWithRemarkFixed, displayAt90, rightScroll(90))).toBeUndefined(); + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(90), {}, displayAt90)).toBe(false); + + const displayAt220 = resolveColumnsForRightFixed(columnsWithRemarkFixed, rightScroll(220)); + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(220), {}, displayAt220)).toBe(false); + }); +}); + +describe('resolveFixedColumnLayout', () => { + it('左右同时存在时统一解析列序与签名', () => { + const columns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120, fixed: 'left' }, + { colKey: 'email', width: 180 }, + { colKey: 'remark', width: 120, fixed: 'right' }, + { colKey: 'city', width: 120 }, + { colKey: 'operation', width: 100, fixed: 'right' }, + ]; + const layout = resolveFixedColumnLayout(columns, { + scrollLeft: 100, + maxScrollLeft: 400, + }); + expect(layout.left.reorderTriggeredKeys).toEqual(['name']); + expect(layout.displayColumns.map((col) => col.colKey)).toEqual([ + 'name', + 'id', + 'email', + 'remark', + 'city', + 'operation', + ]); + }); +}); diff --git a/packages/components/table/_example/fixed-column-reorder.tsx b/packages/components/table/_example/fixed-column-reorder.tsx new file mode 100644 index 0000000000..788c8a1907 --- /dev/null +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -0,0 +1,348 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Radio, Space, Table, Tag } from 'tdesign-react'; + +import type { PrimaryTableRef } from '../interface'; +import type { PrimaryTableCol, TableRowData } from '../type'; + +const TABLE_WIDTH = 720; + +/** + * 全部模式(含 None)共用同一套列定义:仅 `fixed` 不同,列序 / title / width 永不改变。 + * header 始终为:ID | Name | Email | Dept | Address | City | Remark | Operation + */ +const COLUMN_KEYS = ['id', 'name', 'email', 'dept', 'address', 'city', 'remark', 'operation'] as const; + +type ColumnKey = (typeof COLUMN_KEYS)[number]; + +type DemoMode = + | 'none' + | 'leftName' + | 'leftDisconnected' + | 'leftConnected' + | 'rightAddress' + | 'rightDisconnected' + | 'rightConnected'; + +type DemoModeConfig = { + label: string; + leftFixed?: ColumnKey[]; + rightFixed?: ColumnKey[]; + note?: string; +}; + +const LEFT_MODES = ['leftName', 'leftDisconnected', 'leftConnected'] as const satisfies readonly DemoMode[]; +const RIGHT_MODES = ['rightAddress', 'rightDisconnected', 'rightConnected'] as const satisfies readonly DemoMode[]; + +const DEMO_MODES: Record = { + none: { label: 'None' }, + leftName: { + label: 'name', + leftFixed: ['name'], + note: 'Reorder when scrollLeft ≥ 100', + }, + leftDisconnected: { + label: 'disconnected (name+dept)', + leftFixed: ['name', 'dept'], + note: 'name @ 100px, dept @ 400px', + }, + leftConnected: { + label: 'connected (id+name)', + leftFixed: ['id', 'name'], + note: 'Trailing left fixed, no reorder', + }, + rightAddress: { + label: 'address', + rightFixed: ['address'], + note: 'Border until scrollLeft ≥ 20', + }, + rightDisconnected: { + label: 'disconnected (address+remark)', + rightFixed: ['address', 'remark'], + note: 'scroll=0 两列贴右;address 达 scroll≥20 重排并取消 sticky,border 立即交 remark', + }, + rightConnected: { + label: 'connected (remark+operation)', + rightFixed: ['remark', 'operation'], + note: 'Trailing right fixed at end, no reorder', + }, +}; + +const COLUMN_META: Record = { + id: { title: 'ID', width: 100 }, + name: { title: 'Name', width: 120 }, + email: { title: 'Email', width: 180 }, + dept: { title: 'Dept', width: 120 }, + address: { title: 'Address', width: 220 }, + remark: { title: 'Remark', width: 160 }, + city: { title: 'City', width: 120 }, + operation: { title: 'Operation', width: 100 }, +}; + +const TABLE_DATA: TableRowData[] = Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + name: ['Alice', 'Bob', 'Carol'][i % 3], + email: ['a@example.com', 'b@example.com', 'c@example.com'][i % 3], + dept: ['R&D', 'Design', 'Product'][i % 3], + city: ['Shenzhen', 'Shanghai', 'Beijing'][i % 3], + address: ['Nanshan', 'Lujiazui', 'Wangjing'][i % 3], + remark: ['Note A', 'Note B', 'Note C'][i % 3], +})); + +function buildColumn(colKey: ColumnKey): PrimaryTableCol { + const meta = COLUMN_META[colKey]; + if (colKey === 'operation') { + return { + colKey, + title: meta.title, + width: meta.width, + cell: () => ( + + ), + }; + } + return { colKey, title: meta.title, width: meta.width }; +} + +/** 与 None 模式完全相同的列定义(无 fixed) */ +const BASE_COLUMNS: PrimaryTableCol[] = COLUMN_KEYS.map((colKey) => buildColumn(colKey)); + +/** 仅附加 fixed,不改变列序与其它字段 */ +function applyFixedMode(mode: DemoMode): PrimaryTableCol[] { + const { leftFixed = [], rightFixed = [] } = DEMO_MODES[mode]; + const leftSet = new Set(leftFixed); + const rightSet = new Set(rightFixed); + + return BASE_COLUMNS.map((col) => { + const colKey = String(col.colKey) as ColumnKey; + const next: PrimaryTableCol = { ...col }; + delete next.fixed; + if (leftSet.has(colKey)) next.fixed = 'left'; + else if (rightSet.has(colKey)) next.fixed = 'right'; + return next; + }); +} + +function sumColumnWidth(columns: PrimaryTableCol[]): number { + return columns.reduce((sum, col) => sum + Number(col.width ?? 0), 0); +} + +function getHeaderSignature(columns: PrimaryTableCol[]): string { + return columns.map((col) => col.title).join(' | '); +} + +/** 从表头 DOM 读取 colKey 顺序(反映 Table 是否做了列重排) */ +function readDomHeaderColKeys(tableRoot: HTMLElement | null): ColumnKey[] { + if (!tableRoot) return []; + const headerCells = tableRoot.querySelectorAll('thead tr:first-child th[data-colkey]'); + return Array.from(headerCells) + .map((th) => th.getAttribute('data-colkey')) + .filter((key): key is ColumnKey => !!key && COLUMN_KEYS.includes(key as ColumnKey)); +} + +/** 按视口 left 排序,得到肉眼看到的从左到右表头顺序 */ +function readVisualHeaderColKeys(tableRoot: HTMLElement | null): ColumnKey[] { + if (!tableRoot) return []; + const headerCells = tableRoot.querySelectorAll('thead tr:first-child th[data-colkey]'); + return Array.from(headerCells) + .map((th) => ({ + colKey: th.getAttribute('data-colkey') as ColumnKey, + left: th.getBoundingClientRect().left, + })) + .filter((item): item is { colKey: ColumnKey; left: number } => !!item.colKey) + .sort((a, b) => a.left - b.left) + .map((item) => item.colKey); +} + +function colKeysToTitles(keys: ColumnKey[]): string { + return keys.map((key) => COLUMN_META[key].title).join(' | '); +} + +const NONE_COL_KEYS = [...COLUMN_KEYS]; + +function isRightFixedMode(mode: DemoMode): boolean { + return (RIGHT_MODES as readonly DemoMode[]).includes(mode); +} + +function getScrollCompareSuffix( + hasData: boolean, + atScrollStart: boolean, + matchesNone: boolean, + scrolledOkText: string, + mismatchText: string, + rightFixedAtStart = false, +): string { + if (!hasData) return ''; + if (!atScrollStart) return scrolledOkText; + if (rightFixedAtStart) { + return matchesNone ? ' ✗ 未出现右 fixed 贴边' : ' ✓ 右 fixed 已贴边(两列 fixed 正常)'; + } + return matchesNone ? ' ✓ 与 None 一致' : mismatchText; +} + +export default function TableFixedColumnReorder() { + const tableRef = useRef(null); + const [mode, setMode] = useState('none'); + const [scrollLeft, setScrollLeft] = useState(0); + const [domColKeys, setDomColKeys] = useState([]); + const [visualColKeys, setVisualColKeys] = useState([]); + + const modeConfig = DEMO_MODES[mode]; + const columns = useMemo(() => applyFixedMode(mode), [mode]); + const maxScrollLeft = useMemo(() => Math.max(0, sumColumnWidth(columns) - TABLE_WIDTH), [columns]); + const configColKeys = useMemo(() => columns.map((col) => String(col.colKey) as ColumnKey), [columns]); + const configHeader = getHeaderSignature(columns); + const isAtScrollStart = scrollLeft <= 0; + const expectsRightFixedVisualDiff = isRightFixedMode(mode) && isAtScrollStart; + const domMatchesNone = + domColKeys.length === NONE_COL_KEYS.length && domColKeys.every((key, i) => key === NONE_COL_KEYS[i]); + const visualMatchesNone = + visualColKeys.length === NONE_COL_KEYS.length && visualColKeys.every((key, i) => key === NONE_COL_KEYS[i]); + + const readHeaderState = () => { + const tableRoot = tableRef.current?.tableHtmlElement; + const content = tableRef.current?.tableContentElement; + if (content) setScrollLeft(content.scrollLeft); + setDomColKeys(readDomHeaderColKeys(tableRoot ?? null)); + setVisualColKeys(readVisualHeaderColKeys(tableRoot ?? null)); + }; + + // 切换模式时回到 scrollLeft=0,避免沿用上一模式的滚动 / 重排列序 + useEffect(() => { + const content = tableRef.current?.tableContentElement; + if (content) { + if (typeof content.scrollTo === 'function') { + content.scrollTo({ left: 0 }); + } else { + content.scrollLeft = 0; + } + } + setScrollLeft(0); + }, [mode]); + + // 渲染后读取表头状态 + useEffect(() => { + const raf = requestAnimationFrame(readHeaderState); + return () => cancelAnimationFrame(raf); + }, [mode, columns]); + + const handleTableScroll = () => { + requestAnimationFrame(readHeaderState); + }; + + let domTagTheme: 'success' | 'danger' | 'primary' = 'primary'; + if (isAtScrollStart) { + domTagTheme = domMatchesNone ? 'success' : 'danger'; + } + + const domTagSuffix = getScrollCompareSuffix( + domColKeys.length > 0, + isAtScrollStart, + domMatchesNone, + '(滚动/重排后变化属正常)', + ' ✗ 与 None 不一致', + ); + + let visualTagTheme: 'success' | 'danger' | 'primary' = 'primary'; + if (isAtScrollStart) { + if (expectsRightFixedVisualDiff) { + visualTagTheme = visualMatchesNone ? 'danger' : 'success'; + } else { + visualTagTheme = visualMatchesNone ? 'success' : 'danger'; + } + } + + const visualTagSuffix = getScrollCompareSuffix( + visualColKeys.length > 0, + isAtScrollStart, + visualMatchesNone, + ' ✓ 右 fixed 滚动后视觉变化正常', + ' ✗ 与 None 不一致', + expectsRightFixedVisualDiff, + ); + + return ( + + setMode(val)}> + + {DEMO_MODES.none.label} + + + + 左侧 + + {LEFT_MODES.map((key) => ( + + {DEMO_MODES[key].label} + + ))} + + + + + 右侧 + + {RIGHT_MODES.map((key) => ( + + {DEMO_MODES[key].label} + + ))} + + + + + + + + + + + + + columns 定义(colKey): {configColKeys.join(' → ')} + + + columns title: {configHeader} + + + DOM 表头顺序(scrollLeft={scrollLeft}px): {domColKeys.length ? colKeysToTitles(domColKeys) : '读取中…'} + {domTagSuffix} + + + 视觉从左到右(scrollLeft={scrollLeft}px): + {visualColKeys.length ? colKeysToTitles(visualColKeys) : '读取中…'} + {visualTagSuffix} + + {modeConfig.note && ( + + {modeConfig.note} + + )} + {mode !== 'none' && mode !== 'leftConnected' && mode !== 'rightConnected' && ( + + maxScrollLeft ≈ {maxScrollLeft}px(table {TABLE_WIDTH}px) + + )} + + +
+ + ); +} diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index f8be5f345d..d032f6d056 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -8,6 +8,16 @@ import { off, on } from '../../_util/listener'; import useDeepEffect from '../../hooks/useDeepEffect'; import usePrevious from '../../hooks/usePrevious'; import { resizeObserverElement } from '../utils'; +import { buildFixedLayoutState, markFixedColumnBoundaries } from '../utils/markFixedColumnBoundaries'; +import { + getDeferredRightFixedStickyColKeys, + hasFixedColumnNeedReorder, + readFixedLayoutScrollMetricsFromElement, + resolveDisplayColumnsForFixed, + resolveRightBorderBoundaryColKey, + shouldShowLeftFixedColumnShadow, + shouldShowRightFixedColumnShadow, +} from '../utils/reorderFixedColumns'; import type { MutableRefObject } from 'react'; import type { AffixRef } from '../../affix'; @@ -23,7 +33,7 @@ export function getColumnFixedStyles( tableColFixedClasses: TableColFixedClasses, ): { style?: Styles; classes?: ClassName } { const fixedPos = rowAndColFixedPosition?.get(col.colKey || index); - if (!fixedPos) return {}; + if (!fixedPos || fixedPos.deferRightSticky) return {}; const thClasses = { [tableColFixedClasses.left]: col.fixed === 'left', [tableColFixedClasses.right]: col.fixed === 'right', @@ -75,6 +85,7 @@ export function getRowFixedStyles( export default function useFixed( props: TdBaseTableProps, finalColumns: BaseTableCol[], + originColumns: BaseTableCol[] = finalColumns, affixRef?: { paginationAffixRef: MutableRefObject; horizontalScrollAffixRef: MutableRefObject; @@ -100,6 +111,7 @@ export default function useFixed( const tableContentRef = useRef(null); const tableElmRef = useRef(null); const thWidthList = useRef<{ [colKey: string]: number }>({}); + const lastFixedBorderSignatureRef = useRef(''); const [data, setData] = useState([]); const [isFixedHeader, setIsFixedHeader] = useState(false); @@ -115,13 +127,14 @@ export default function useFixed( right: false, }); // 虚拟滚动无法使用 CSS sticky 固定表头 - const [virtualScrollHeaderPos, setVirtualScrollHeaderPos] = useState<{ left: number; top: number }>({ + const [virtualScrollHeaderPos, setVirtualScrollHeaderPos] = useState<{ + left: number; + top: number; + }>({ left: 0, top: 0, }); const [isFixedColumn, setIsFixedColumn] = useState(false); - const [isFixedRightColumn, setIsFixedRightColumn] = useState(false); - const [isFixedLeftColumn, setIsFixedLeftColumn] = useState(false); // 没有表头吸顶,没有虚拟滚动,则不需要表头宽度计算 const notNeedThWidthList = useMemo( @@ -139,6 +152,52 @@ export default function useFixed( tableElmRef.current = val; } + const getFixedLayoutScrollMetrics = () => + readFixedLayoutScrollMetricsFromElement(tableContentRef.current, originColumns, thWidthList.current); + + const calculateThWidthList = (trList: HTMLCollection) => { + const widthMap: { [colKey: string]: number } = {}; + for (let i = 0, len = trList?.length; i < len; i++) { + const thList = trList[i].children; + for (let j = 0, thLen = thList.length; j < thLen; j++) { + const th = thList[j] as HTMLElement; + const colKey = th.dataset.colkey; + widthMap[colKey] = th.getBoundingClientRect().width; + } + } + return widthMap; + }; + + const updateThWidthList = (trList: HTMLCollection | { [colKey: string]: number }) => { + if (trList instanceof HTMLCollection) { + if (columnResizable) return; + thWidthList.current = calculateThWidthList(trList); + } else { + thWidthList.current = thWidthList.current || {}; + Object.entries(trList).forEach(([colKey, width]) => { + thWidthList.current[colKey] = width; + }); + } + return thWidthList.current; + }; + + const getThWidthList = (type?: 'default' | 'calculate') => { + if (type === 'calculate') { + const trList = tableContentRef.current?.querySelector('thead')?.children; + return calculateThWidthList(trList); + } + return thWidthList.current || {}; + }; + + /** 内置重排时以 originColumns + 当前 scroll 解析展示列,避免 finalColumns 滞后导致 border 标记错乱 */ + const resolveDisplayColumns = (displayColumnsOverride?: BaseTableCol[]) => { + if (displayColumnsOverride) return displayColumnsOverride; + if (hasFixedColumnNeedReorder(originColumns)) { + return resolveDisplayColumnsForFixed(originColumns, getFixedLayoutScrollMetrics(), getThWidthList()); + } + return finalColumns; + }; + function getColumnMap( columns: BaseTableCol[], map: RowAndColFixedPosition = new Map(), @@ -151,12 +210,6 @@ export default function useFixed( if (['left', 'right'].includes(col.fixed)) { setIsFixedColumn(true); } - if (col.fixed === 'right') { - setIsFixedRightColumn(true); - } - if (col.fixed === 'left') { - setIsFixedLeftColumn(true); - } const key = col.colKey || i; const columnInfo: FixedColumnInfo = { col, parent, index: i }; map.set(key, columnInfo); @@ -209,7 +262,10 @@ export default function useFixed( for (let i = columns.length - 1; i >= 0; i--) { const col = columns[i]; if (col.fixed === 'left') return; - const colInfo = initialColumnMap.get(col.colKey || i); + const colKey = col.colKey || i; + const colInfo = initialColumnMap.get(colKey); + if (!colInfo) continue; + if (col.fixed === 'right' && colInfo.deferRightSticky) continue; let lastColIndex = i + 1; while (lastColIndex < columns.length && columns[lastColIndex].fixed !== 'right') { lastColIndex += 1; @@ -226,8 +282,26 @@ export default function useFixed( } }; + const applyDeferredRightStickyFlags = ( + initialColumnMap: RowAndColFixedPosition, + originCols: BaseTableCol[] = originColumns, + scrollMetrics = getFixedLayoutScrollMetrics(), + ) => { + const deferredKeys = getDeferredRightFixedStickyColKeys(originCols, scrollMetrics, getThWidthList()); + deferredKeys.forEach((colKey) => { + const colInfo = initialColumnMap.get(colKey); + if (colInfo?.col?.fixed === 'right') { + initialColumnMap.set(colKey, { ...colInfo, deferRightSticky: true }); + } + }); + }; + // 获取固定列位置信息。先获取节点宽度,再计算 - const setFixedColPosition = (trList: HTMLCollection, initialColumnMap: RowAndColFixedPosition) => { + const setFixedColPosition = ( + trList: HTMLCollection, + initialColumnMap: RowAndColFixedPosition, + displayColumns: BaseTableCol[] = finalColumns, + ) => { if (!trList) return; for (let i = 0, len = trList.length; i < len; i++) { const thList = trList[i].children; @@ -239,12 +313,16 @@ export default function useFixed( } const obj = initialColumnMap.get(colKey || j); if (obj?.col?.fixed) { - initialColumnMap.set(colKey, { ...obj, width: th?.getBoundingClientRect?.().width }); + initialColumnMap.set(colKey, { + ...obj, + width: th?.getBoundingClientRect?.().width, + }); } } } - setFixedLeftPos(columns, initialColumnMap); - setFixedRightPos(columns, initialColumnMap); + setFixedLeftPos(displayColumns, initialColumnMap); + applyDeferredRightStickyFlags(initialColumnMap, originColumns, getFixedLayoutScrollMetrics()); + setFixedRightPos(displayColumns, initialColumnMap); }; // 设置固定行位置信息 top/bottom @@ -267,7 +345,10 @@ export default function useFixed( defaultBottom = thead?.getBoundingClientRect?.().height || 0; } thisRowInfo.top = (lastRowInfo.top || defaultBottom) + (lastRowInfo.height || 0); - initialColumnMap.set(rowId, { ...thisRowInfo, height: tr?.getBoundingClientRect?.().height }); + initialColumnMap.set(rowId, { + ...thisRowInfo, + height: tr?.getBoundingClientRect?.().height, + }); } for (let i = data.length - 1; i >= data.length - fixedBottomRows; i--) { /** @@ -285,68 +366,143 @@ export default function useFixed( defaultBottom = tfoot?.getBoundingClientRect?.().height || 0; } thisRowInfo.bottom = (lastRowInfo.bottom || defaultBottom) + (lastRowInfo.height || 0); - initialColumnMap.set(rowId, { ...thisRowInfo, height: tr?.getBoundingClientRect?.().height }); + initialColumnMap.set(rowId, { + ...thisRowInfo, + height: tr?.getBoundingClientRect?.().height, + }); } }; - const updateRowAndColFixedPosition = (tableContentElm: HTMLElement, initialColumnMap: RowAndColFixedPosition) => { - rowAndColFixedPosition.clear(); - if (!tableContentElm) return; + const updateRowAndColFixedPosition = ( + tableContentElm: HTMLElement | null, + initialColumnMap: RowAndColFixedPosition, + displayColumns: BaseTableCol[] = finalColumns, + ) => { + if (!tableContentElm) { + setRowAndColFixedPosition(new Map(initialColumnMap)); + return; + } const thead = tableContentElm.querySelector('thead'); // 处理固定列 - thead && setFixedColPosition(thead.children, initialColumnMap); + thead && setFixedColPosition(thead.children, initialColumnMap, displayColumns); // 处理冻结行 const tbody = tableContentElm.querySelector('tbody'); const tfoot = tableContentElm.querySelector('tfoot'); tbody && setFixedRowPosition(tbody.children, initialColumnMap, thead, tfoot); - // 更新最终 Map - setRowAndColFixedPosition(initialColumnMap); + // 克隆 Map 引用,确保 lastLeftFixedCol 变化能触发重渲染 + setRowAndColFixedPosition(new Map(initialColumnMap)); }; + /** 将边界标记写入已有 fixed 位置 Map,供横向滚动轻量 border 同步 */ + const applyFixedBoundaryFlagsToMap = ( + columnMap: RowAndColFixedPosition, + levelNodes: FixedColumnInfo[][], + ): RowAndColFixedPosition => { + const next = new Map(columnMap); + for (let t = 0, tLen = levelNodes.length; t < tLen; t++) { + const nodes = levelNodes[t]; + for (let i = 0, len = nodes.length; i < len; i++) { + const node = nodes[i]; + const key = node.col.colKey ?? node.index; + const existing = next.get(key); + if (existing) { + next.set(key, { + ...existing, + lastLeftFixedCol: node.lastLeftFixedCol, + firstRightFixedCol: node.firstRightFixedCol, + deferRightSticky: existing.deferRightSticky, + }); + } else { + next.set(key, { ...node }); + } + } + } + return next; + }; + + const hasFixedSideColumn = (cols: BaseTableCol[] = [], side: 'left' | 'right'): boolean => + cols.some((col) => { + if (col.fixed === side) return true; + return col.children?.length ? hasFixedSideColumn(col.children, side) : false; + }); + let shadowLastScrollLeft: number; - const updateColumnFixedShadow = (target: HTMLElement, extra?: { skipScrollLimit?: boolean }) => { + const updateColumnFixedShadow = ( + target: HTMLElement, + extra?: { skipScrollLimit?: boolean }, + displayColumnsOverride?: BaseTableCol[], + ) => { if (!isFixedColumn || !target) return; const { scrollLeft } = target; // 只有左右滚动,需要更新固定列阴影 if (shadowLastScrollLeft === scrollLeft && (!extra || !extra.skipScrollLimit)) return; shadowLastScrollLeft = scrollLeft; - const isShowRight = target.clientWidth + scrollLeft < target.scrollWidth; - const isShowLeft = scrollLeft > 0; + const scrollMetrics = getFixedLayoutScrollMetrics(); + const colWidths = getThWidthList(); + const displayColumns = resolveDisplayColumns(displayColumnsOverride); + const isShowRight = shouldShowRightFixedColumnShadow(originColumns, scrollMetrics, colWidths, displayColumns); + const isShowLeft = shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths); if (showColumnShadow.left === isShowLeft && showColumnShadow.right === isShowRight) return; setShowColumnShadow({ - left: isShowLeft && isFixedLeftColumn, - right: isShowRight && isFixedRightColumn, + left: isShowLeft && hasFixedSideColumn(originColumns, 'left'), + right: isShowRight && hasFixedSideColumn(originColumns, 'right'), }); }; - // 多级表头场景较为复杂:为了滚动的阴影效果,需要知道哪些列是边界列,左侧固定列的最后一列,右侧固定列的第一列,每一层表头都需要兼顾 - const setIsLastOrFirstFixedCol = (levelNodes: FixedColumnInfo[][]) => { - for (let t = 0; t < levelNodes.length; t++) { - const nodes = levelNodes[t]; - for (let i = 0, len = nodes.length; i < len; i++) { - const colMapInfo = nodes[i]; - const nextColMapInfo = nodes[i + 1]; - const { parent } = colMapInfo; - const isParentLastLeftFixedCol = !parent || parent?.lastLeftFixedCol; - if (isParentLastLeftFixedCol && colMapInfo.col.fixed === 'left' && nextColMapInfo?.col.fixed !== 'left') { - colMapInfo.lastLeftFixedCol = true; - } - const lastColMapInfo = nodes[i - 1]; - const isParentFirstRightFixedCol = !parent || parent?.firstRightFixedCol; - if (isParentFirstRightFixedCol && colMapInfo.col.fixed === 'right' && lastColMapInfo?.col.fixed !== 'right') { - colMapInfo.firstRightFixedCol = true; - } - } - } + // 多级表头场景较为复杂:为了滚动的阴影效果,需要知道哪些列是边界列 + const buildLayoutForBoundaryMark = ( + displayColumns: BaseTableCol[] = finalColumns, + scrollMetrics = getFixedLayoutScrollMetrics(), + ) => { + const colWidths = getThWidthList(); + const layout = buildFixedLayoutState(originColumns, displayColumns, scrollMetrics, colWidths); + if (!layout.right.enabled) return layout; + + const borderBoundaryColKey = resolveRightBorderBoundaryColKey( + originColumns, + displayColumns, + scrollMetrics, + colWidths, + ); + const showShadow = shouldShowRightFixedColumnShadow(originColumns, scrollMetrics, colWidths, displayColumns); + const right = { + ...layout.right, + borderBoundaryColKey, + showShadow, + sideLayoutSignature: `${borderBoundaryColKey ?? ''}|${showShadow ? 1 : 0}|${layout.right.reorderSignature}`, + }; + + return { + ...layout, + right, + layoutSignature: `${layout.left.sideLayoutSignature}::${right.sideLayoutSignature}`, + }; }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const updateFixedStatus = () => { - const { newColumnsMap, levelNodes } = getColumnMap(columns); - setIsLastOrFirstFixedCol(levelNodes); - if (isFixedColumn || fixedRows?.length) { - updateRowAndColFixedPosition(tableContentRef.current, newColumnsMap); + const hasFixedColumns = (cols: BaseTableCol[] = []): boolean => + cols.some((col) => { + if (col.fixed === 'left' || col.fixed === 'right') return true; + return col.children?.length ? hasFixedColumns(col.children) : false; + }); + + const updateFixedStatus = (displayColumnsOverride?: BaseTableCol[]) => { + setIsFixedColumn(false); + const scrollMetrics = getFixedLayoutScrollMetrics(); + const displayColumns = resolveDisplayColumns(displayColumnsOverride); + const { newColumnsMap, levelNodes } = getColumnMap(displayColumns); + + if (hasFixedColumns(displayColumns) || fixedRows?.length) { + // 先算 sticky 偏移,再据 right 偏移标记 fixed-right-first(与 setFixedRightPos 同源) + updateRowAndColFixedPosition(tableContentRef.current, newColumnsMap, displayColumns); + const layoutForMark = buildLayoutForBoundaryMark(displayColumns, scrollMetrics); + lastFixedBorderSignatureRef.current = layoutForMark.layoutSignature; + markFixedColumnBoundaries(levelNodes, layoutForMark); + setRowAndColFixedPosition((prev) => applyFixedBoundaryFlagsToMap(prev.size ? prev : newColumnsMap, levelNodes)); + } else { + lastFixedBorderSignatureRef.current = ''; + setRowAndColFixedPosition(new Map()); } + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }, displayColumns); }; // 使用 useCallback 来优化性能 @@ -389,33 +545,6 @@ export default function useFixed( affixRef.footerBottomAffixRef.current?.handleScroll?.(); }; - const calculateThWidthList = (trList: HTMLCollection) => { - const widthMap: { [colKey: string]: number } = {}; - for (let i = 0, len = trList?.length; i < len; i++) { - const thList = trList[i].children; - // second for used for multiple row header - for (let j = 0, thLen = thList.length; j < thLen; j++) { - const th = thList[j] as HTMLElement; - const colKey = th.dataset.colkey; - widthMap[colKey] = th.getBoundingClientRect().width; - } - } - return widthMap; - }; - - const updateThWidthList = (trList: HTMLCollection | { [colKey: string]: number }) => { - if (trList instanceof HTMLCollection) { - if (columnResizable) return; - thWidthList.current = calculateThWidthList(trList); - } else { - thWidthList.current = thWidthList.current || {}; - Object.entries(trList).forEach(([colKey, width]) => { - thWidthList.current[colKey] = width; - }); - } - return thWidthList.current; - }; - const updateThWidthListHandler = () => { if (notNeedThWidthList) return; const thead = tableContentRef.current?.querySelector('thead'); @@ -429,14 +558,6 @@ export default function useFixed( props.onScroll?.({ e }); }; - const getThWidthList = (type?: 'default' | 'calculate') => { - if (type === 'calculate') { - const trList = tableContentRef.current?.querySelector('thead')?.children; - return calculateThWidthList(trList); - } - return thWidthList.current || {}; - }; - const updateTableElmWidthOnColumnChange = ( finalColumns: BaseTableCol[] = [], preFinalColumns: BaseTableCol[] = [], @@ -467,7 +588,7 @@ export default function useFixed( // eslint-disable-next-line react-hooks/exhaustive-deps [ data, - columns, + finalColumns, bordered, tableLayout, tableContentWidth, @@ -484,10 +605,10 @@ export default function useFixed( // eslint-disable-next-line react-hooks/exhaustive-deps useDeepEffect(() => { if (isFixedColumn) { - updateColumnFixedShadow(tableContentRef.current); + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }, resolveDisplayColumns()); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFixedColumn, columns, tableContentRef]); + }, [isFixedColumn, finalColumns, tableContentRef]); useDeepEffect(updateFixedHeader, [maxHeight, data, columns, bordered, tableContentRef]); @@ -523,7 +644,9 @@ export default function useFixed( if (isFixedColumn || isFixedHeader) { updateFixedStatus(); - updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }); + updateColumnFixedShadow(tableContentRef.current, { + skipScrollLimit: true, + }); } }; @@ -578,6 +701,22 @@ export default function useFixed( updateFixedHeader(); }; + /** 横向滚动时同步 fixed 边界(左 last / 右 first),优先用已算好的 sticky right 偏移 */ + const syncFixedColumnBorder = (displayColumnsOverride?: BaseTableCol[]) => { + if (!hasFixedColumnNeedReorder(originColumns)) return; + const scrollMetrics = getFixedLayoutScrollMetrics(); + const displayColumns = resolveDisplayColumns(displayColumnsOverride); + const { newColumnsMap, levelNodes } = getColumnMap(displayColumns); + const layoutForMark = buildLayoutForBoundaryMark(displayColumns, scrollMetrics); + if (lastFixedBorderSignatureRef.current === layoutForMark.layoutSignature) return; + lastFixedBorderSignatureRef.current = layoutForMark.layoutSignature; + + markFixedColumnBoundaries(levelNodes, layoutForMark); + setRowAndColFixedPosition((prev) => applyFixedBoundaryFlagsToMap(prev.size ? prev : newColumnsMap, levelNodes)); + + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }, displayColumns); + }; + return { tableWidth, tableElmWidth, @@ -600,5 +739,6 @@ export default function useFixed( getThWidthList, updateThWidthList, updateTableAfterColumnResize, + syncFixedColumnBorder, }; } diff --git a/packages/components/table/hooks/useRowExpand.tsx b/packages/components/table/hooks/useRowExpand.tsx index 501d26547d..7a8232fbb7 100644 --- a/packages/components/table/hooks/useRowExpand.tsx +++ b/packages/components/table/hooks/useRowExpand.tsx @@ -49,7 +49,8 @@ function useRowExpand(props: TdPrimaryTableProps) { const showExpandIconColumn = props.expandIcon !== false && showExpandedRow; - const isFirstColumnFixed = props.columns?.[0]?.fixed === 'left'; + // 存在任意左固定列时,展开列也需要固定(非首列 left fixed 滚动后前置) + const hasLeftFixedColumn = props.columns?.some((col) => col.fixed === 'left'); const onToggleExpand = (e: MouseEvent, row: TableRowData) => { props.expandOnRowClick && e.stopPropagation(); @@ -93,7 +94,7 @@ function useRowExpand(props: TdPrimaryTableProps) { colKey: '__EXPAND_ROW_ICON_COLUMN__', width: 46, className: tableExpandClasses.iconCell, - fixed: isFirstColumnFixed ? 'left' : undefined, + fixed: hasLeftFixedColumn ? 'left' : undefined, cell: (p) => renderExpandIcon(p, expandIcon), stopPropagation: true, }; @@ -101,7 +102,10 @@ function useRowExpand(props: TdPrimaryTableProps) { }; const renderExpandedRow = ( - p: TableExpandedRowParams & { tableWidth: number; isWidthOverflow: boolean }, + p: TableExpandedRowParams & { + tableWidth: number; + isWidthOverflow: boolean; + }, ) => { const rowId = get(p.row, props.rowKey || 'id'); if (!tExpandedRowKeys || !tExpandedRowKeys.includes(rowId)) return null; diff --git a/packages/components/table/interface.ts b/packages/components/table/interface.ts index 6d4d509ef0..5fdeedbe98 100644 --- a/packages/components/table/interface.ts +++ b/packages/components/table/interface.ts @@ -104,6 +104,8 @@ export interface FixedColumnInfo { index?: number; lastLeftFixedCol?: boolean; firstRightFixedCol?: boolean; + /** 多列右不相连:未达重排阈值前暂不参与 sticky(与左 fixed 初始保持定义顺序对称) */ + deferRightSticky?: boolean; } // 固定表头和固定列 具体的固定位置(left/top/right/bottom) diff --git a/packages/components/table/utils/markFixedColumnBoundaries.ts b/packages/components/table/utils/markFixedColumnBoundaries.ts new file mode 100644 index 0000000000..018d51fa79 --- /dev/null +++ b/packages/components/table/utils/markFixedColumnBoundaries.ts @@ -0,0 +1,82 @@ +import { resolveFixedColumnLayout } from './reorderFixedColumns'; + +import type { FixedColumnInfo } from '../interface'; +import type { BaseTableCol, TableRowData } from '../type'; +import type { FixedColumnLayoutState, FixedLayoutScrollMetrics } from './reorderFixedColumns'; + +/** 重置 fixed 边界标记 */ +function resetFixedBoundaryFlags(levelNodes: FixedColumnInfo[][]): void { + for (let t = 0, tLen = levelNodes.length; t < tLen; t++) { + const nodes = levelNodes[t]; + for (let i = 0, len = nodes.length; i < len; i++) { + nodes[i].lastLeftFixedCol = false; + nodes[i].firstRightFixedCol = false; + } + } +} + +/** + * 标记 fixed-left-last / fixed-right-first 边界列。 + * + * 左右两侧独立判断,互不影响: + * - 单侧启用内置重排:仅按 borderBoundaryColKey 标记,不回落默认规则 + * - 单侧未启用重排:与 develop 一致,按列序标记首个边界(与 showShadow / 是否溢出无关) + */ +export function markFixedColumnBoundaries( + levelNodes: FixedColumnInfo[][], + layout: FixedColumnLayoutState, +): void { + resetFixedBoundaryFlags(levelNodes); + + for (let t = 0, tLen = levelNodes.length; t < tLen; t++) { + const nodes = levelNodes[t]; + for (let i = 0, len = nodes.length; i < len; i++) { + const colMapInfo = nodes[i]; + const nextColMapInfo = nodes[i + 1]; + const lastColMapInfo = nodes[i - 1]; + const { parent } = colMapInfo; + const isParentLastLeftFixedCol = !parent || parent?.lastLeftFixedCol; + const isParentFirstRightFixedCol = !parent || parent?.firstRightFixedCol; + + if (layout.left.enabled) { + if (layout.left.borderBoundaryColKey && colMapInfo.col.colKey === layout.left.borderBoundaryColKey) { + colMapInfo.lastLeftFixedCol = true; + } + } else if (isParentLastLeftFixedCol && colMapInfo.col.fixed === 'left' && nextColMapInfo?.col.fixed !== 'left') { + colMapInfo.lastLeftFixedCol = true; + } + + if (layout.right.enabled) { + if (layout.right.borderBoundaryColKey && colMapInfo.col.colKey === layout.right.borderBoundaryColKey) { + colMapInfo.firstRightFixedCol = true; + } + } else if ( + isParentFirstRightFixedCol && + colMapInfo.col.fixed === 'right' && + lastColMapInfo?.col.fixed !== 'right' + ) { + colMapInfo.firstRightFixedCol = true; + } + } + } +} + +/** 构建 useFixed 所需的 fixed 布局快照 */ +export function buildFixedLayoutState( + originColumns: BaseTableCol[], + displayColumns: BaseTableCol[], + scrollMetrics: FixedLayoutScrollMetrics, + colWidths: Record, +): FixedColumnLayoutState { + return resolveFixedColumnLayout(originColumns, scrollMetrics, colWidths, displayColumns); +} + +/** @deprecated 使用 buildFixedLayoutState */ +export function buildLeftFixedLayoutState( + originColumns: BaseTableCol[], + displayColumns: BaseTableCol[], + scrollLeft: number, + colWidths: Record, +): FixedColumnLayoutState { + return resolveFixedColumnLayout(originColumns, { scrollLeft, maxScrollLeft: 0 }, colWidths, displayColumns); +} diff --git a/packages/components/table/utils/reorderFixedColumns.ts b/packages/components/table/utils/reorderFixedColumns.ts new file mode 100644 index 0000000000..c569a7f5e9 --- /dev/null +++ b/packages/components/table/utils/reorderFixedColumns.ts @@ -0,0 +1,854 @@ +import log from '@tdesign/common-js/log/index'; + +import type { BaseTableCol, TableRowData } from '../type'; + +/** 始终保持在最左侧的功能列,不参与左固定重排 */ +export const TABLE_LEADING_COLUMN_KEYS = new Set(['__EXPAND_ROW_ICON_COLUMN__', 'row-select', 'drag', 'serial-number']); + +/** 横向滚动度量(右侧贴边阈值依赖 maxScrollLeft) */ +export interface FixedLayoutScrollMetrics { + scrollLeft: number; + maxScrollLeft: number; +} + +/** 单侧 fixed 重排触发项 */ +export interface FixedReorderTriggerEntry { + colKey: string; + /** 左:scrollLeft >= widthBefore;右:scrollFromRight <= widthAfter */ + threshold: number; + widthAfter?: number; +} + +/** 单侧 fixed 布局状态 */ +export interface FixedColumnSideLayoutState { + enabled: boolean; + reorderTriggeredKeys: string[]; + borderBoundaryColKey?: string; + showShadow: boolean; + reorderSignature: string; + sideLayoutSignature: string; +} + +/** 非首列 fixed 完整布局状态(左 + 右) */ +export interface FixedColumnLayoutState { + enabled: boolean; + displayColumns: BaseTableCol[]; + left: FixedColumnSideLayoutState; + right: FixedColumnSideLayoutState; + layoutSignature: string; +} + +/** @deprecated 兼容旧引用 */ +export type LeftFixedReorderTriggerEntry = FixedReorderTriggerEntry; + +/** @deprecated 兼容旧引用 */ +export interface LeftFixedLayoutState { + enabled: boolean; + displayColumns: BaseTableCol[]; + reorderTriggeredKeys: string[]; + borderBoundaryColKey?: string; + showLeftShadow: boolean; + reorderSignature: string; + layoutSignature: string; +} + +/** 解析列宽配置为像素值 */ +export function parseColumnWidth(width?: string | number): number { + if (typeof width === 'number' && !Number.isNaN(width)) return width; + if (typeof width === 'string') { + const n = parseFloat(width); + return Number.isNaN(n) ? 0 : n; + } + return 0; +} + +function getColumnWidth(col: BaseTableCol, colWidths: Record): number { + const colKey = String(col.colKey ?? ''); + return colWidths[colKey] ?? parseColumnWidth(col.width); +} + +function isLeadingColumn(col: BaseTableCol): boolean { + return Boolean(col.colKey && TABLE_LEADING_COLUMN_KEYS.has(col.colKey)); +} + +/** 距右缘剩余可滚距离(DOM 仅有 scrollLeft,右向判断统一用此量) */ +export function getScrollFromRight(scrollMetrics: FixedLayoutScrollMetrics): number { + const { scrollLeft, maxScrollLeft } = scrollMetrics; + return Math.max(0, maxScrollLeft - scrollLeft); +} + +export function getColumnsTotalWidth( + columns: BaseTableCol[] = [], + colWidths: Record = {}, +): number { + let total = 0; + for (let i = 0, len = columns.length; i < len; i++) { + total += getColumnWidth(columns[i], colWidths); + } + return total; +} + +export function createFixedLayoutScrollMetrics( + scrollLeft = 0, + scrollWidth = 0, + clientWidth = 0, +): FixedLayoutScrollMetrics { + return { + scrollLeft, + maxScrollLeft: Math.max(0, scrollWidth - clientWidth), + }; +} + +/** + * 从滚动容器读取横向滚动度量;列 fixed 切换后 DOM scrollWidth 可能滞后,用列宽总和兜底 maxScrollLeft。 + */ +export function readFixedLayoutScrollMetricsFromElement( + el: HTMLElement | null, + originColumns: BaseTableCol[] = [], + colWidths: Record = {}, +): FixedLayoutScrollMetrics { + if (!el) return createFixedLayoutScrollMetrics(0, 0, 0); + const metrics = createFixedLayoutScrollMetrics(el.scrollLeft, el.scrollWidth, el.clientWidth); + if (metrics.maxScrollLeft > 0 || !hasFixedColumnNeedReorder(originColumns)) { + return metrics; + } + const totalWidth = getColumnsTotalWidth(originColumns, colWidths); + const fallbackMax = Math.max(0, totalWidth - el.clientWidth); + if (fallbackMax <= 0) return metrics; + return { scrollLeft: el.scrollLeft, maxScrollLeft: fallbackMax }; +} + +// ---------- 左 fixed ---------- + +export function hasLeftFixedColumnNeedReorder(columns: BaseTableCol[] = []): boolean { + let metScrollable = false; + for (let i = 0, len = columns.length; i < len; i++) { + const col = columns[i]; + if (isLeadingColumn(col)) continue; + if (col.fixed === 'right') break; + if (col.fixed === 'left' && metScrollable) return true; + if (!col.fixed) metScrollable = true; + } + return false; +} + +export function getLeftFixedReorderTriggerEntries( + columns: BaseTableCol[] = [], + colWidths: Record = {}, +): FixedReorderTriggerEntry[] { + const entries: FixedReorderTriggerEntry[] = []; + let widthBefore = 0; + let metScrollable = false; + + for (let i = 0, len = columns.length; i < len; i++) { + const col = columns[i]; + if (isLeadingColumn(col)) { + widthBefore += getColumnWidth(col, colWidths); + continue; + } + if (col.fixed === 'right') break; + + if (col.fixed === 'left' && metScrollable) { + entries.push({ colKey: String(col.colKey ?? i), threshold: widthBefore }); + } + + widthBefore += getColumnWidth(col, colWidths); + if (!col.fixed) metScrollable = true; + } + + return entries; +} + +export function getLeftFixedReorderTriggerScrollLeft( + columns: BaseTableCol[] = [], + colWidths: Record = {}, +): number { + const entries = getLeftFixedReorderTriggerEntries(columns, colWidths); + return entries.length ? entries[0].threshold : Infinity; +} + +export function getTriggeredLeftFixedColKeys( + columns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): string[] { + if (scrollLeft <= 0 || !hasLeftFixedColumnNeedReorder(columns)) return []; + return getLeftFixedReorderTriggerEntries(columns, colWidths) + .filter((entry) => scrollLeft >= entry.threshold) + .map((entry) => entry.colKey); +} + +export function getTriggeredLeftFixedSignature( + columns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): string { + return getTriggeredLeftFixedColKeys(columns, scrollLeft, colWidths).join('|'); +} + +export function getLeftFixedBorderBoundaryColKey( + originColumns: BaseTableCol[] = [], + displayColumns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): string | undefined { + if (scrollLeft <= 0 || !hasLeftFixedColumnNeedReorder(originColumns)) { + return undefined; + } + + let tableOffset = 0; + let leftFixedWidthSum = 0; + let lastStickyActiveKey: string | undefined; + + for (let i = 0, len = displayColumns.length; i < len; i++) { + const col = displayColumns[i]; + const colKey = String(col.colKey ?? i); + const width = getColumnWidth(col, colWidths); + + if (isLeadingColumn(col)) { + tableOffset += width; + continue; + } + if (col.fixed === 'right') break; + + if (col.fixed === 'left') { + const activationScrollLeft = Math.max(0, tableOffset - leftFixedWidthSum); + if (scrollLeft >= activationScrollLeft) { + lastStickyActiveKey = colKey; + } + leftFixedWidthSum += width; + } + tableOffset += width; + } + + return lastStickyActiveKey; +} + +/** + * 与 setFixedRightPos 相同规则,基于 display 列序与列宽计算 right fixed 的 sticky right 偏移。 + * border 边界取已触发列中 right 偏移最大者(贴边栈最左缘,与 fixed-right-first 一致)。 + */ +export function computeRightFixedOffsets( + displayColumns: BaseTableCol[] = [], + colWidths: Record = {}, +): Map { + const offsets = new Map(); + const rightFixedMeta = new Map(); + + for (let i = displayColumns.length - 1; i >= 0; i--) { + const col = displayColumns[i]; + if (col.fixed === 'left') break; + + const colKey = String(col.colKey ?? i); + const width = getColumnWidth(col, colWidths); + + if (col.fixed === 'right') { + let lastColIndex = i + 1; + while (lastColIndex < displayColumns.length && displayColumns[lastColIndex].fixed !== 'right') { + lastColIndex += 1; + } + const lastCol = displayColumns[lastColIndex]; + const lastKey = lastCol ? String(lastCol.colKey ?? lastColIndex) : ''; + const lastMeta = lastKey ? rightFixedMeta.get(lastKey) : undefined; + const right = (lastMeta?.right ?? 0) + (lastMeta?.width ?? 0); + rightFixedMeta.set(colKey, { right, width }); + offsets.set(colKey, right); + } + } + + return offsets; +} + +/** 在已触发列中,取 right 偏移最大者作为 fixed-right-first 边界列 */ +export function pickRightFixedBorderBoundaryColKey( + triggeredColKeys: Set | string[] = [], + rightOffsets: Map = new Map(), +): string | undefined { + const keys = triggeredColKeys instanceof Set ? triggeredColKeys : new Set(triggeredColKeys); + let boundaryColKey: string | undefined; + let maxRight = -1; + + keys.forEach((colKey) => { + const right = rightOffsets.get(colKey) ?? 0; + if (right >= maxRight) { + maxRight = right; + boundaryColKey = colKey; + } + }); + + return boundaryColKey; +} + +/** 从 fixed 位置 Map 读取已算好的 sticky right 偏移 */ +export function readRightFixedOffsetsFromColumnMap( + columnMap: Map = new Map(), +): Map { + const offsets = new Map(); + columnMap.forEach((info, key) => { + if (info.col?.fixed === 'right' && info.right !== undefined) { + offsets.set(String(key), info.right); + } + }); + return offsets; +} + +/** 右固定 border / 阴影是否应显示(与标准右固定一致:可横向滚动且未滚到最右) */ +export function isRightFixedBorderScrollActive(scrollMetrics: FixedLayoutScrollMetrics): boolean { + const { scrollLeft, maxScrollLeft } = scrollMetrics; + return maxScrollLeft > 0 && scrollLeft < maxScrollLeft; +} + +/** 参与内置重排的 right fixed 列 colKey 列表(定义顺序,从右往左) */ +export function getRightFixedReorderColKeys( + columns: BaseTableCol[] = [], + colWidths: Record = {}, +): string[] { + return getRightFixedReorderTriggerEntries(columns, colWidths).map((entry) => entry.colKey); +} + +/** 是否仅有一列 right fixed(如「右:固定 address」),border 与标准右固定一致 */ +export function isSingleIsolatedRightFixedColumn(columns: BaseTableCol[] = []): boolean { + if (getRightFixedReorderColKeys(columns).length !== 1) return false; + let rightFixedCount = 0; + for (let i = 0, len = columns.length; i < len; i++) { + if (columns[i].fixed === 'right') rightFixedCount += 1; + } + return rightFixedCount === 1; +} + +/** + * 多列不相连:与单列 address 同语义——未达重排阈值前贴右缘才有 border。 + * 在仍贴边的列中取 widthAfter 最大者(最内侧贴边列)作为 fixed-right-first。 + */ +function getColumnWidthAfterInOrder( + columns: BaseTableCol[] = [], + targetColKey: string, + colWidths: Record = {}, +): number { + let widthAfter = 0; + for (let i = columns.length - 1; i >= 0; i--) { + const colKey = String(columns[i].colKey ?? i); + if (colKey === targetColKey) return widthAfter; + widthAfter += getColumnWidth(columns[i], colWidths); + } + return -1; +} + +function getRightFixedMultiBorderBoundaryColKey( + originColumns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics, + colWidths: Record = {}, +): string | undefined { + const { maxScrollLeft } = scrollMetrics; + const scrollFromRight = getScrollFromRight(scrollMetrics); + const entries = getRightFixedReorderTriggerEntries(originColumns, colWidths); + const triggered = new Set(getTriggeredRightFixedColKeys(originColumns, scrollMetrics, colWidths)); + const trailing = getRightFixedTrailingColKeysForReorder(originColumns); + const entryColKeys = new Set(entries.map((entry) => entry.colKey)); + + let boundaryColKey: string | undefined; + let maxStickingWidthAfter = -1; + + const considerCandidate = (colKey: string, widthAfter: number) => { + if (triggered.has(colKey)) return; + if (widthAfter > maxScrollLeft) return; + if (scrollFromRight > widthAfter && widthAfter > maxStickingWidthAfter) { + maxStickingWidthAfter = widthAfter; + boundaryColKey = colKey; + } + }; + + // 已有列触发重排:border 立即交给仍未触发的贴边列(避免内侧列仍占 border) + if (triggered.size > 0) { + for (let i = 0, len = entries.length; i < len; i++) { + const entry = entries[i]; + considerCandidate(entry.colKey, entry.widthAfter ?? 0); + } + return boundaryColKey; + } + + for (let i = 0, len = entries.length; i < len; i++) { + const entry = entries[i]; + considerCandidate(entry.colKey, entry.widthAfter ?? 0); + } + + // 末段 trailing 列(如 operation)可能不在 reorder entries 中 + trailing.forEach((colKey) => { + if (entryColKeys.has(colKey)) return; + const widthAfter = getColumnWidthAfterInOrder(originColumns, colKey, colWidths); + if (widthAfter < 0) return; + considerCandidate(colKey, widthAfter); + }); + + return boundaryColKey; +} + +/** + * 右侧 border 边界列(fixed-right-first)。 + * - 仅一列 right fixed(如 address):scrollFromRight > widthAfter 时贴右缘有 border;达重排阈值后脱离右边界,border 清除。 + * - 多列不相连:各列均在 scrollFromRight > widthAfter 时贴右缘;取 widthAfter 最大且仍贴边者为边界,达阈值后交接给下一列。 + */ +export function getRightFixedStickyBoundaryColKey( + originColumns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, +): string | undefined { + const { maxScrollLeft } = scrollMetrics; + if (!hasRightFixedColumnNeedReorder(originColumns)) return undefined; + if (!isRightFixedBorderScrollActive(scrollMetrics)) return undefined; + + // 单列右固定:未达重排阈值前贴右缘;达阈值后列脱离右边界,不再加粗 + if (isSingleIsolatedRightFixedColumn(originColumns)) { + const entry = getRightFixedReorderTriggerEntries(originColumns, colWidths)[0]; + if (!entry) return undefined; + const widthAfter = entry.widthAfter ?? 0; + if (widthAfter > maxScrollLeft) return undefined; + if (getScrollFromRight(scrollMetrics) > widthAfter) { + return entry.colKey; + } + return undefined; + } + + return getRightFixedMultiBorderBoundaryColKey(originColumns, scrollMetrics, colWidths); +} + +/** 结合布局快照,解析右侧 border 边界 */ +export function resolveRightBorderBoundaryColKey( + originColumns: BaseTableCol[], + displayColumns: BaseTableCol[], + scrollMetrics: FixedLayoutScrollMetrics, + colWidths: Record, +): string | undefined { + return getRightFixedStickyBoundaryColKey(originColumns, scrollMetrics, colWidths); +} + +/** @alias resolveRightBorderBoundaryColKey */ +export function getRightFixedBorderBoundaryColKey( + originColumns: BaseTableCol[] = [], + displayColumns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, +): string | undefined { + return resolveRightBorderBoundaryColKey(originColumns, displayColumns, scrollMetrics, colWidths); +} + +export function isLeftFixedReorderTriggered( + columns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): boolean { + return getTriggeredLeftFixedColKeys(columns, scrollLeft, colWidths).length > 0; +} + +export function shouldShowLeftFixedColumnShadow( + columns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): boolean { + if (scrollLeft <= 0) return false; + if (hasLeftFixedColumnNeedReorder(columns)) { + // 与左 border(sticky)相反:左阴影跟重排阈值 + return isLeftFixedReorderTriggered(columns, scrollLeft, colWidths); + } + return true; +} + +export function reorderColumnsForLeftFixedPartial( + columns: BaseTableCol[] = [], + triggeredColKeys: Set = new Set(), +): BaseTableCol[] { + if (!columns.length || !triggeredColKeys.size) return columns; + + const hasMultiHeader = columns.some((col) => col.children?.length); + if (hasMultiHeader) { + log.warn('TDesign Table', '非首列左固定暂不支持多级表头,已跳过重排。请使用平铺列或手动调整 columns 顺序。'); + return columns; + } + + const leading: BaseTableCol[] = []; + const triggeredLeftFixed: BaseTableCol[] = []; + const middle: BaseTableCol[] = []; + + for (let i = 0, len = columns.length; i < len; i++) { + const col = columns[i]; + const colKey = String(col.colKey ?? i); + + if (isLeadingColumn(col)) { + leading.push(col); + continue; + } + if (col.fixed === 'left' && triggeredColKeys.has(colKey)) { + triggeredLeftFixed.push(col); + continue; + } + middle.push(col); + } + + return [...leading, ...triggeredLeftFixed, ...middle]; +} + +export function resolveColumnsForLeftFixed( + columns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): BaseTableCol[] { + if (!hasLeftFixedColumnNeedReorder(columns)) return columns; + const triggeredKeys = getTriggeredLeftFixedColKeys(columns, scrollLeft, colWidths); + if (!triggeredKeys.length) return columns; + return reorderColumnsForLeftFixedPartial(columns, new Set(triggeredKeys)); +} + +export function reorderColumnsForLeftFixed(columns: BaseTableCol[] = []): BaseTableCol[] { + if (!columns.length || !hasLeftFixedColumnNeedReorder(columns)) return columns; + const allKeys = getLeftFixedReorderTriggerEntries(columns).map((entry) => entry.colKey); + return reorderColumnsForLeftFixedPartial(columns, new Set(allKeys)); +} + +function buildLeftSideLayoutState( + originColumns: BaseTableCol[], + displayColumns: BaseTableCol[], + scrollLeft: number, + colWidths: Record, +): FixedColumnSideLayoutState { + const enabled = hasLeftFixedColumnNeedReorder(originColumns); + + if (!enabled) { + return { + enabled: false, + reorderTriggeredKeys: [], + borderBoundaryColKey: undefined, + showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), + reorderSignature: '', + sideLayoutSignature: '', + }; + } + + const reorderTriggeredKeys = getTriggeredLeftFixedColKeys(originColumns, scrollLeft, colWidths); + const borderBoundaryColKey = getLeftFixedBorderBoundaryColKey(originColumns, displayColumns, scrollLeft, colWidths); + const reorderSignature = reorderTriggeredKeys.join('|'); + + return { + enabled: true, + reorderTriggeredKeys, + borderBoundaryColKey, + showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), + reorderSignature, + sideLayoutSignature: `${borderBoundaryColKey ?? ''}|${reorderSignature}`, + }; +} + +export function resolveLeftFixedLayout( + originColumns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, + displayColumnsOverride?: BaseTableCol[], +): LeftFixedLayoutState { + const displayColumns = displayColumnsOverride ?? resolveColumnsForLeftFixed(originColumns, scrollLeft, colWidths); + const left = buildLeftSideLayoutState(originColumns, displayColumns, scrollLeft, colWidths); + + return { + enabled: left.enabled, + displayColumns, + reorderTriggeredKeys: left.reorderTriggeredKeys, + borderBoundaryColKey: left.borderBoundaryColKey, + showLeftShadow: left.showShadow, + reorderSignature: left.reorderSignature, + layoutSignature: left.sideLayoutSignature, + }; +} + +// ---------- 右 fixed(与左侧对称) ---------- + +export function hasRightFixedColumnNeedReorder(columns: BaseTableCol[] = []): boolean { + let metScrollable = false; + for (let i = columns.length - 1; i >= 0; i--) { + const col = columns[i]; + if (col.fixed === 'left') break; + if (col.fixed === 'right' && metScrollable) return true; + if (!col.fixed) metScrollable = true; + } + return false; +} + +export function hasFixedColumnNeedReorder(columns: BaseTableCol[] = []): boolean { + return hasLeftFixedColumnNeedReorder(columns) || hasRightFixedColumnNeedReorder(columns); +} + +export function getRightFixedReorderTriggerEntries( + columns: BaseTableCol[] = [], + colWidths: Record = {}, +): FixedReorderTriggerEntry[] { + const entries: FixedReorderTriggerEntry[] = []; + let widthAfter = 0; + let metScrollable = false; + + for (let i = columns.length - 1; i >= 0; i--) { + const col = columns[i]; + if (col.fixed === 'left') break; + + if (col.fixed === 'right' && metScrollable) { + entries.unshift({ + colKey: String(col.colKey ?? i), + threshold: 0, + widthAfter, + }); + } + + widthAfter += getColumnWidth(col, colWidths); + if (!col.fixed) metScrollable = true; + } + + return entries; +} + +export function getTriggeredRightFixedColKeys( + columns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, +): string[] { + const { scrollLeft, maxScrollLeft } = scrollMetrics; + if (maxScrollLeft <= 0 || scrollLeft <= 0 || !hasRightFixedColumnNeedReorder(columns)) return []; + + const scrollFromRight = getScrollFromRight(scrollMetrics); + + return getRightFixedReorderTriggerEntries(columns, colWidths) + .filter((entry) => { + const widthAfter = entry.widthAfter ?? 0; + if (widthAfter > maxScrollLeft) return false; + return scrollFromRight <= widthAfter; + }) + .map((entry) => entry.colKey); +} + +export function isRightFixedReorderTriggered( + columns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, +): boolean { + return getTriggeredRightFixedColKeys(columns, scrollMetrics, colWidths).length > 0; +} + +export function shouldShowRightFixedColumnShadow( + columns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, + displayColumns: BaseTableCol[] = [], +): boolean { + // 容器阴影:重排模式跟随 border 边界;标准右固定与 develop 一致,看是否还能右滚 + if (hasRightFixedColumnNeedReorder(columns)) { + const display = displayColumns.length ? displayColumns : columns; + return !!getRightFixedBorderBoundaryColKey(columns, display, scrollMetrics, colWidths); + } + const { scrollLeft, maxScrollLeft } = scrollMetrics; + return scrollLeft < maxScrollLeft; +} + +function getRightFixedTrailingColKeysForReorder(columns: BaseTableCol[] = []): Set { + let lastRightFixedIndex = -1; + for (let i = columns.length - 1; i >= 0; i--) { + if (columns[i].fixed === 'right') { + lastRightFixedIndex = i; + break; + } + } + if (lastRightFixedIndex < 0) return new Set(); + const headPart = columns.slice(0, lastRightFixedIndex + 1); + return new Set(getTrailingRightFixedColumns(headPart).map((col) => String(col.colKey ?? ''))); +} + +/** + * 多列右不相连:已触发重排的列取消 sticky,border 交给外侧下一列。 + * 单列右固定(如 address)不延迟。 + */ +export function getDeferredRightFixedStickyColKeys( + originColumns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, +): Set { + if (isSingleIsolatedRightFixedColumn(originColumns)) return new Set(); + if (!hasRightFixedColumnNeedReorder(originColumns)) return new Set(); + return new Set(getTriggeredRightFixedColKeys(originColumns, scrollMetrics, colWidths)); +} + +function getTrailingRightFixedColumns(columns: BaseTableCol[] = []): BaseTableCol[] { + const trailing: BaseTableCol[] = []; + for (let i = columns.length - 1; i >= 0; i--) { + const col = columns[i]; + if (col.fixed === 'right') { + trailing.unshift(col); + } else { + break; + } + } + return trailing; +} + +export function reorderColumnsForRightFixedPartial( + columns: BaseTableCol[] = [], + triggeredColKeys: Set = new Set(), +): BaseTableCol[] { + if (!columns.length || !triggeredColKeys.size) return columns; + + const hasMultiHeader = columns.some((col) => col.children?.length); + if (hasMultiHeader) { + log.warn('TDesign Table', '非末列右固定暂不支持多级表头,已跳过重排。请使用平铺列或手动调整 columns 顺序。'); + return columns; + } + + let lastRightFixedIndex = -1; + for (let i = columns.length - 1; i >= 0; i--) { + if (columns[i].fixed === 'right') { + lastRightFixedIndex = i; + break; + } + } + + // 最后一个 right fixed 之后的列(如 operation)始终保持在最末 + const absoluteTail = lastRightFixedIndex >= 0 ? columns.slice(lastRightFixedIndex + 1) : []; + const headPart = lastRightFixedIndex >= 0 ? columns.slice(0, lastRightFixedIndex + 1) : columns; + const trailingRightFixed = getTrailingRightFixedColumns(headPart); + const trailingKeys = new Set(trailingRightFixed.map((col) => String(col.colKey ?? ''))); + + const leading: BaseTableCol[] = []; + const leftFixed: BaseTableCol[] = []; + const body: BaseTableCol[] = []; + const triggeredRightFixed: BaseTableCol[] = []; + + for (let i = 0, len = headPart.length; i < len; i++) { + const col = headPart[i]; + const colKey = String(col.colKey ?? i); + + if (isLeadingColumn(col)) { + leading.push(col); + continue; + } + if (col.fixed === 'left') { + leftFixed.push(col); + continue; + } + if (col.fixed === 'right') { + if (trailingKeys.has(colKey)) continue; + if (triggeredColKeys.has(colKey)) triggeredRightFixed.push(col); + else body.push(col); + continue; + } + body.push(col); + } + + return [...leading, ...leftFixed, ...body, ...triggeredRightFixed, ...trailingRightFixed, ...absoluteTail]; +} + +export function resolveColumnsForRightFixed( + columns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, + originColumns?: BaseTableCol[], +): BaseTableCol[] { + const origin = originColumns ?? columns; + if (!hasRightFixedColumnNeedReorder(origin)) return columns; + const triggeredKeys = getTriggeredRightFixedColKeys(origin, scrollMetrics, colWidths); + if (!triggeredKeys.length) return columns; + return reorderColumnsForRightFixedPartial(columns, new Set(triggeredKeys)); +} + +export function reorderColumnsForRightFixed( + columns: BaseTableCol[] = [], +): BaseTableCol[] { + if (!columns.length || !hasRightFixedColumnNeedReorder(columns)) return columns; + const allKeys = getRightFixedReorderTriggerEntries(columns).map((entry) => entry.colKey); + return reorderColumnsForRightFixedPartial(columns, new Set(allKeys)); +} + +function buildRightSideLayoutState( + originColumns: BaseTableCol[], + displayColumns: BaseTableCol[], + scrollMetrics: FixedLayoutScrollMetrics, + colWidths: Record, +): FixedColumnSideLayoutState { + const enabled = hasRightFixedColumnNeedReorder(originColumns); + + if (!enabled) { + return { + enabled: false, + reorderTriggeredKeys: [], + borderBoundaryColKey: undefined, + showShadow: shouldShowRightFixedColumnShadow(originColumns, scrollMetrics, colWidths, displayColumns), + reorderSignature: '', + sideLayoutSignature: '', + }; + } + + const reorderTriggeredKeys = getTriggeredRightFixedColKeys(originColumns, scrollMetrics, colWidths); + const borderBoundaryColKey = getRightFixedBorderBoundaryColKey( + originColumns, + displayColumns, + scrollMetrics, + colWidths, + ); + const reorderSignature = reorderTriggeredKeys.join('|'); + + return { + enabled: true, + reorderTriggeredKeys, + borderBoundaryColKey, + showShadow: shouldShowRightFixedColumnShadow(originColumns, scrollMetrics, colWidths, displayColumns), + reorderSignature, + sideLayoutSignature: `${borderBoundaryColKey ?? ''}|${reorderSignature}`, + }; +} + +// ---------- 统一入口 ---------- + +export function resolveDisplayColumnsForFixed( + originColumns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, +): BaseTableCol[] { + const afterLeft = resolveColumnsForLeftFixed(originColumns, scrollMetrics.scrollLeft, colWidths); + return resolveColumnsForRightFixed(afterLeft, scrollMetrics, colWidths, originColumns); +} + +export function resolveFixedColumnLayout( + originColumns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, + displayColumnsOverride?: BaseTableCol[], +): FixedColumnLayoutState { + const displayColumns = + displayColumnsOverride ?? resolveDisplayColumnsForFixed(originColumns, scrollMetrics, colWidths); + const left = buildLeftSideLayoutState(originColumns, displayColumns, scrollMetrics.scrollLeft, colWidths); + const right = buildRightSideLayoutState(originColumns, displayColumns, scrollMetrics, colWidths); + const enabled = left.enabled || right.enabled; + + return { + enabled, + displayColumns, + left, + right, + layoutSignature: `${left.sideLayoutSignature}::${right.sideLayoutSignature}`, + }; +} + +// ---------- 通用工具 ---------- + +export function isSameColumnOrder( + prev: BaseTableCol[] = [], + next: BaseTableCol[] = [], +): boolean { + if (prev.length !== next.length) return false; + for (let i = 0, len = prev.length; i < len; i++) { + if (prev[i].colKey !== next[i].colKey) return false; + } + return true; +} + +export function getColumnFixedSignature(columns: BaseTableCol[] = []): string { + return columns.map((col) => `${col.colKey ?? ''}:${col.fixed ?? ''}`).join('|'); +} + +export function isSameDisplayColumns( + prev: BaseTableCol[] = [], + next: BaseTableCol[] = [], +): boolean { + return isSameColumnOrder(prev, next) && getColumnFixedSignature(prev) === getColumnFixedSignature(next); +} diff --git a/packages/components/table/utils/scheduleAfterColumnDomUpdate.ts b/packages/components/table/utils/scheduleAfterColumnDomUpdate.ts new file mode 100644 index 0000000000..b0baea2fe5 --- /dev/null +++ b/packages/components/table/utils/scheduleAfterColumnDomUpdate.ts @@ -0,0 +1,17 @@ +/** + * 列序 DOM 更新后执行回调(双 rAF),确保 thead/tbody 列顺序与 fixed 位置计算一致。 + * @returns 取消函数 + */ +export function scheduleAfterColumnDomUpdate(callback: () => void): () => void { + let cancelled = false; + requestAnimationFrame(() => { + if (cancelled) return; + requestAnimationFrame(() => { + if (cancelled) return; + callback(); + }); + }); + return () => { + cancelled = true; + }; +} diff --git a/packages/tdesign-react/site/src/components/Demo.jsx b/packages/tdesign-react/site/src/components/Demo.jsx index 59765b29f5..957adf6dab 100644 --- a/packages/tdesign-react/site/src/components/Demo.jsx +++ b/packages/tdesign-react/site/src/components/Demo.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; - import { Button } from '@tdesign/components'; + import DraggableButton from './DraggableButton'; export const demoFiles = import.meta.glob('../../../../components/**/_example/*.tsx'); diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index fbb288e7fa..d3e260d969 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -113164,6 +113164,844 @@ exports[`csr snapshot test > csr test packages/components/table/_example/fixed-c `; +exports[`csr snapshot test > csr test packages/components/table/_example/fixed-column-reorder.tsx 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+ + 左侧 + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ + 右侧 + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ + columns 定义(colKey): + id → name → email → dept → address → city → remark → operation + +
+
+
+
+ + columns title: + ID | Name | Email | Dept | Address | City | Remark | Operation + +
+
+
+
+ + DOM 表头顺序(scrollLeft= + 0 + px): + 读取中… + +
+
+
+
+ + 视觉从左到右(scrollLeft= + 0 + px): + 读取中… + +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ID +
+
+
+ Name +
+
+
+ Email +
+
+
+ Dept +
+
+
+ Address +
+
+
+ City +
+
+
+ Remark +
+
+
+ Operation +
+
+ 1 + + Alice + + a@example.com + + R&D + + Nanshan + + Shenzhen + + Note A + + +
+ 2 + + Bob + + b@example.com + + Design + + Lujiazui + + Shanghai + + Note B + + +
+ 3 + + Carol + + c@example.com + + Product + + Wangjing + + Beijing + + Note C + + +
+ 4 + + Alice + + a@example.com + + R&D + + Nanshan + + Shenzhen + + Note A + + +
+ 5 + + Bob + + b@example.com + + Design + + Lujiazui + + Shanghai + + Note B + + +
+ 6 + + Carol + + c@example.com + + Product + + Wangjing + + Beijing + + Note C + + +
+ 7 + + Alice + + a@example.com + + R&D + + Nanshan + + Shenzhen + + Note A + + +
+ 8 + + Bob + + b@example.com + + Design + + Lujiazui + + Shanghai + + Note B + + +
+ 9 + + Carol + + c@example.com + + Product + + Wangjing + + Beijing + + Note C + + +
+ 10 + + Alice + + a@example.com + + R&D + + Nanshan + + Shenzhen + + Note A + + +
+ + + + + +`; + exports[`csr snapshot test > csr test packages/components/table/_example/fixed-header.tsx 1`] = `
ssr test packages/components/table/_example/filter- exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-column.tsx 1`] = `"
申请人
审批状态
邮箱地址
申请事项
申请日期
操作
贾明
审批通过
w.cezkdudy@lhll.au宣传物料制作费用2022-01-01查看详情
张三
审批失败
r.nmgw@peurezgn.slalgolia 服务报销2022-02-01再次申请
王芳
审批过期
p.cumx@rampblpa.ru相关周边制作费2022-03-01再次申请
贾明
审批通过
w.cezkdudy@lhll.au激励奖品快递费2022-04-01查看详情
张三
审批失败
r.nmgw@peurezgn.sl宣传物料制作费用2022-01-01再次申请
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-column-reorder.tsx 1`] = `"
左侧
右侧
columns 定义(colKey): id → name → email → dept → address → city → remark → operation
columns title: ID | Name | Email | Dept | Address | City | Remark | Operation
DOM 表头顺序(scrollLeft=0px): 读取中…
视觉从左到右(scrollLeft=0px):读取中…
ID
Name
Email
Dept
Address
City
Remark
Operation
1Alicea@example.comR&DNanshanShenzhenNote A
2Bobb@example.comDesignLujiazuiShanghaiNote B
3Carolc@example.comProductWangjingBeijingNote C
4Alicea@example.comR&DNanshanShenzhenNote A
5Bobb@example.comDesignLujiazuiShanghaiNote B
6Carolc@example.comProductWangjingBeijingNote C
7Alicea@example.comR&DNanshanShenzhenNote A
8Bobb@example.comDesignLujiazuiShanghaiNote B
9Carolc@example.comProductWangjingBeijingNote C
10Alicea@example.comR&DNanshanShenzhenNote A
"`; + exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-header.tsx 1`] = `"
申请人
审批状态
申请事项
邮箱地址
申请日期
操作
贾明
审批通过
宣传物料制作费用
w.cezkdudy@lhll.au
2022-01-01查看详情
张三
审批失败
algolia 服务报销
r.nmgw@peurezgn.sl
2022-02-01再次申请
王芳
审批过期
相关周边制作费
p.cumx@rampblpa.ru
2022-03-01再次申请
贾明
审批通过
激励奖品快递费
w.cezkdudy@lhll.au
2022-04-01查看详情
张三
审批失败
宣传物料制作费用
r.nmgw@peurezgn.sl
2022-01-01再次申请
王芳
审批过期
algolia 服务报销
p.cumx@rampblpa.ru
2022-02-01再次申请
贾明
审批通过
相关周边制作费
w.cezkdudy@lhll.au
2022-03-01查看详情
张三
审批失败
激励奖品快递费
r.nmgw@peurezgn.sl
2022-04-01再次申请
王芳
审批过期
宣传物料制作费用
p.cumx@rampblpa.ru
2022-01-01再次申请
贾明
审批通过
algolia 服务报销
w.cezkdudy@lhll.au
2022-02-01查看详情
张三
审批失败
相关周边制作费
r.nmgw@peurezgn.sl
2022-03-01再次申请
王芳
审批过期
激励奖品快递费
p.cumx@rampblpa.ru
2022-04-01再次申请
贾明
审批通过
宣传物料制作费用
w.cezkdudy@lhll.au
2022-01-01查看详情
张三
审批失败
algolia 服务报销
r.nmgw@peurezgn.sl
2022-02-01再次申请
王芳
审批过期
相关周边制作费
p.cumx@rampblpa.ru
2022-03-01再次申请
贾明
审批通过
激励奖品快递费
w.cezkdudy@lhll.au
2022-04-01查看详情
张三
审批失败
宣传物料制作费用
r.nmgw@peurezgn.sl
2022-01-01再次申请
王芳
审批过期
algolia 服务报销
p.cumx@rampblpa.ru
2022-02-01再次申请
贾明
审批通过
相关周边制作费
w.cezkdudy@lhll.au
2022-03-01查看详情
张三
审批失败
激励奖品快递费
r.nmgw@peurezgn.sl
2022-04-01再次申请
------
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-header-col.tsx 1`] = `"
申请人
审批状态
签署方式
申请事项
邮箱地址
申请日期
操作
贾明
审批通过
电子签署宣传物料制作费用w.cezkdudy@lhll.au2022-01-01查看详情
张三
审批失败
纸质签署algolia 服务报销r.nmgw@peurezgn.sl2022-02-01再次申请
王芳
审批过期
纸质签署相关周边制作费p.cumx@rampblpa.ru2022-03-01再次申请
贾明
审批通过
电子签署激励奖品快递费w.cezkdudy@lhll.au2022-04-01查看详情
张三
审批失败
纸质签署宣传物料制作费用r.nmgw@peurezgn.sl2022-01-01再次申请
王芳
审批过期
纸质签署algolia 服务报销p.cumx@rampblpa.ru2022-02-01再次申请
贾明
审批通过
电子签署相关周边制作费w.cezkdudy@lhll.au2022-03-01查看详情
张三
审批失败
纸质签署激励奖品快递费r.nmgw@peurezgn.sl2022-04-01再次申请
王芳
审批过期
纸质签署宣传物料制作费用p.cumx@rampblpa.ru2022-01-01再次申请
贾明
审批通过
电子签署algolia 服务报销w.cezkdudy@lhll.au2022-02-01查看详情
张三
审批失败
纸质签署相关周边制作费r.nmgw@peurezgn.sl2022-03-01再次申请
王芳
审批过期
纸质签署激励奖品快递费p.cumx@rampblpa.ru2022-04-01再次申请
贾明
审批通过
电子签署宣传物料制作费用w.cezkdudy@lhll.au2022-01-01查看详情
张三
审批失败
纸质签署algolia 服务报销r.nmgw@peurezgn.sl2022-02-01再次申请
王芳
审批过期
纸质签署相关周边制作费p.cumx@rampblpa.ru2022-03-01再次申请
贾明
审批通过
电子签署激励奖品快递费w.cezkdudy@lhll.au2022-04-01查看详情
张三
审批失败
纸质签署宣传物料制作费用r.nmgw@peurezgn.sl2022-01-01再次申请
王芳
审批过期
纸质签署algolia 服务报销p.cumx@rampblpa.ru2022-02-01再次申请
贾明
审批通过
电子签署相关周边制作费w.cezkdudy@lhll.au2022-03-01查看详情
张三
审批失败
纸质签署激励奖品快递费r.nmgw@peurezgn.sl2022-04-01再次申请
共20条----
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index f327059e29..3a09784cee 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -1022,6 +1022,8 @@ exports[`ssr snapshot test > ssr test packages/components/table/_example/filter- exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-column.tsx 1`] = `"
申请人
审批状态
邮箱地址
申请事项
申请日期
操作
贾明
审批通过
w.cezkdudy@lhll.au宣传物料制作费用2022-01-01查看详情
张三
审批失败
r.nmgw@peurezgn.slalgolia 服务报销2022-02-01再次申请
王芳
审批过期
p.cumx@rampblpa.ru相关周边制作费2022-03-01再次申请
贾明
审批通过
w.cezkdudy@lhll.au激励奖品快递费2022-04-01查看详情
张三
审批失败
r.nmgw@peurezgn.sl宣传物料制作费用2022-01-01再次申请
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-column-reorder.tsx 1`] = `"
左侧
右侧
columns 定义(colKey): id → name → email → dept → address → city → remark → operation
columns title: ID | Name | Email | Dept | Address | City | Remark | Operation
DOM 表头顺序(scrollLeft=0px): 读取中…
视觉从左到右(scrollLeft=0px):读取中…
ID
Name
Email
Dept
Address
City
Remark
Operation
1Alicea@example.comR&DNanshanShenzhenNote A
2Bobb@example.comDesignLujiazuiShanghaiNote B
3Carolc@example.comProductWangjingBeijingNote C
4Alicea@example.comR&DNanshanShenzhenNote A
5Bobb@example.comDesignLujiazuiShanghaiNote B
6Carolc@example.comProductWangjingBeijingNote C
7Alicea@example.comR&DNanshanShenzhenNote A
8Bobb@example.comDesignLujiazuiShanghaiNote B
9Carolc@example.comProductWangjingBeijingNote C
10Alicea@example.comR&DNanshanShenzhenNote A
"`; + exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-header.tsx 1`] = `"
申请人
审批状态
申请事项
邮箱地址
申请日期
操作
贾明
审批通过
宣传物料制作费用
w.cezkdudy@lhll.au
2022-01-01查看详情
张三
审批失败
algolia 服务报销
r.nmgw@peurezgn.sl
2022-02-01再次申请
王芳
审批过期
相关周边制作费
p.cumx@rampblpa.ru
2022-03-01再次申请
贾明
审批通过
激励奖品快递费
w.cezkdudy@lhll.au
2022-04-01查看详情
张三
审批失败
宣传物料制作费用
r.nmgw@peurezgn.sl
2022-01-01再次申请
王芳
审批过期
algolia 服务报销
p.cumx@rampblpa.ru
2022-02-01再次申请
贾明
审批通过
相关周边制作费
w.cezkdudy@lhll.au
2022-03-01查看详情
张三
审批失败
激励奖品快递费
r.nmgw@peurezgn.sl
2022-04-01再次申请
王芳
审批过期
宣传物料制作费用
p.cumx@rampblpa.ru
2022-01-01再次申请
贾明
审批通过
algolia 服务报销
w.cezkdudy@lhll.au
2022-02-01查看详情
张三
审批失败
相关周边制作费
r.nmgw@peurezgn.sl
2022-03-01再次申请
王芳
审批过期
激励奖品快递费
p.cumx@rampblpa.ru
2022-04-01再次申请
贾明
审批通过
宣传物料制作费用
w.cezkdudy@lhll.au
2022-01-01查看详情
张三
审批失败
algolia 服务报销
r.nmgw@peurezgn.sl
2022-02-01再次申请
王芳
审批过期
相关周边制作费
p.cumx@rampblpa.ru
2022-03-01再次申请
贾明
审批通过
激励奖品快递费
w.cezkdudy@lhll.au
2022-04-01查看详情
张三
审批失败
宣传物料制作费用
r.nmgw@peurezgn.sl
2022-01-01再次申请
王芳
审批过期
algolia 服务报销
p.cumx@rampblpa.ru
2022-02-01再次申请
贾明
审批通过
相关周边制作费
w.cezkdudy@lhll.au
2022-03-01查看详情
张三
审批失败
激励奖品快递费
r.nmgw@peurezgn.sl
2022-04-01再次申请
------
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-header-col.tsx 1`] = `"
申请人
审批状态
签署方式
申请事项
邮箱地址
申请日期
操作
贾明
审批通过
电子签署宣传物料制作费用w.cezkdudy@lhll.au2022-01-01查看详情
张三
审批失败
纸质签署algolia 服务报销r.nmgw@peurezgn.sl2022-02-01再次申请
王芳
审批过期
纸质签署相关周边制作费p.cumx@rampblpa.ru2022-03-01再次申请
贾明
审批通过
电子签署激励奖品快递费w.cezkdudy@lhll.au2022-04-01查看详情
张三
审批失败
纸质签署宣传物料制作费用r.nmgw@peurezgn.sl2022-01-01再次申请
王芳
审批过期
纸质签署algolia 服务报销p.cumx@rampblpa.ru2022-02-01再次申请
贾明
审批通过
电子签署相关周边制作费w.cezkdudy@lhll.au2022-03-01查看详情
张三
审批失败
纸质签署激励奖品快递费r.nmgw@peurezgn.sl2022-04-01再次申请
王芳
审批过期
纸质签署宣传物料制作费用p.cumx@rampblpa.ru2022-01-01再次申请
贾明
审批通过
电子签署algolia 服务报销w.cezkdudy@lhll.au2022-02-01查看详情
张三
审批失败
纸质签署相关周边制作费r.nmgw@peurezgn.sl2022-03-01再次申请
王芳
审批过期
纸质签署激励奖品快递费p.cumx@rampblpa.ru2022-04-01再次申请
贾明
审批通过
电子签署宣传物料制作费用w.cezkdudy@lhll.au2022-01-01查看详情
张三
审批失败
纸质签署algolia 服务报销r.nmgw@peurezgn.sl2022-02-01再次申请
王芳
审批过期
纸质签署相关周边制作费p.cumx@rampblpa.ru2022-03-01再次申请
贾明
审批通过
电子签署激励奖品快递费w.cezkdudy@lhll.au2022-04-01查看详情
张三
审批失败
纸质签署宣传物料制作费用r.nmgw@peurezgn.sl2022-01-01再次申请
王芳
审批过期
纸质签署algolia 服务报销p.cumx@rampblpa.ru2022-02-01再次申请
贾明
审批通过
电子签署相关周边制作费w.cezkdudy@lhll.au2022-03-01查看详情
张三
审批失败
纸质签署激励奖品快递费r.nmgw@peurezgn.sl2022-04-01再次申请
共20条----
"`;