From 758ca7414461b8aa7b2c91bc2b5a08e99f84ce00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Wed, 24 Jun 2026 03:43:20 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(table):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=9D=9E=E9=A6=96=E5=88=97=E5=B7=A6=E5=9B=BA=E5=AE=9A=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=E9=87=8D=E6=8E=92=20fixedColumnReorder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/table/BaseTable.tsx | 54 ++++++-- .../__tests__/reorderFixedColumns.test.ts | 42 ++++++ .../table/_example/fixed-column-reorder.tsx | 126 ++++++++++++++++++ .../components/table/hooks/useRowExpand.tsx | 10 +- packages/components/table/interface.ts | 6 + packages/components/table/table.md | 1 + .../table/utils/reorderFixedColumns.ts | 61 +++++++++ 7 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 packages/components/table/__tests__/reorderFixedColumns.test.ts create mode 100644 packages/components/table/_example/fixed-column-reorder.tsx create mode 100644 packages/components/table/utils/reorderFixedColumns.ts diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 6e41e9ddf3..11541647af 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -23,6 +23,7 @@ import TFoot from './TFoot'; import THead from './THead'; import { ROW_LISTENERS } from './TR'; import { getAffixProps } from './utils'; +import { reorderColumnsForLeftFixed } from './utils/reorderFixedColumns'; import type { RefAttributes } from 'react'; import type { AffixRef } from '../affix'; @@ -47,7 +48,8 @@ const BaseTable = forwardRef((originalProps, ref) tableLayout, height, data, - columns, + columns: originColumns, + fixedColumnReorder, style, headerAffixedTop, bordered, @@ -56,6 +58,12 @@ const BaseTable = forwardRef((originalProps, ref) pagination, } = props; + // 左固定列前置重排,opt-in 开启,默认保持原有列顺序 + const columns = useMemo( + () => (fixedColumnReorder ? reorderColumnsForLeftFixed(originColumns) : originColumns), + [originColumns, fixedColumnReorder], + ); + const borderWidth = props.bordered ? 1 : 0; const tableRef = useRef(null); @@ -68,7 +76,9 @@ const BaseTable = forwardRef((originalProps, ref) 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], @@ -183,7 +193,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()) { @@ -391,7 +401,10 @@ const BaseTable = forwardRef((originalProps, ref) > {renderColGroup(true)} {showHeader && } @@ -452,15 +465,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 +609,7 @@ const BaseTable = forwardRef((originalProps, ref) > ), // eslint-disable-next-line - [ + [ isFixedHeader, rowAndColFixedPosition, spansAndLeafNodes, @@ -627,9 +655,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__/reorderFixedColumns.test.ts b/packages/components/table/__tests__/reorderFixedColumns.test.ts new file mode 100644 index 0000000000..3ed82bf1a0 --- /dev/null +++ b/packages/components/table/__tests__/reorderFixedColumns.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { hasLeftFixedColumnNeedReorder, reorderColumnsForLeftFixed } from '../utils/reorderFixedColumns'; + +import type { BaseTableCol } from '../type'; + +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('从左连续固定的列顺序不变', () => { + 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); + }); +}); 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..e2e57f7eba --- /dev/null +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -0,0 +1,126 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { Button, Checkbox, Radio, Space, Table, Tag } from 'tdesign-react'; + +import type { PrimaryTableCol, TableProps } from 'tdesign-react'; + +const data: TableProps['data'] = []; +for (let i = 0; i < 10; i++) { + data.push({ + id: i + 1, + name: ['张三', '李四', '王芳'][i % 3], + email: ['a@example.com', 'b@example.com', 'c@example.com'][i % 3], + dept: ['研发', '设计', '产品'][i % 3], + city: ['深圳', '上海', '北京'][i % 3], + address: ['南山区科技园', '浦东新区陆家嘴', '朝阳区望京'][i % 3], + remark: ['备注 A', '备注 B', '备注 C'][i % 3], + }); +} + +/** 固定列目标:name 为第二列数据,用于验证非首列左吸 */ +type FixedTarget = 'none' | 'name' | 'dept'; + +export default function TableFixedColumnReorder() { + const tableRef = useRef(null); + const [fixedColumnReorder, setFixedColumnReorder] = useState(true); + const [fixedTarget, setFixedTarget] = useState('name'); + const [showRowSelect, setShowRowSelect] = useState(false); + + const baseColumns: PrimaryTableCol[] = useMemo( + () => [ + { colKey: 'id', title: 'ID(定义第 1 列)', width: 100 }, + { colKey: 'name', title: '姓名(定义第 2 列)', width: 120 }, + { colKey: 'email', title: '邮箱', width: 180 }, + { colKey: 'dept', title: '部门(定义第 4 列)', width: 120 }, + { colKey: 'city', title: '城市', width: 120 }, + { colKey: 'address', title: '地址', width: 220 }, + { colKey: 'remark', title: '备注', width: 160 }, + { + colKey: 'operation', + title: '操作', + width: 100, + fixed: 'right', + cell: () => ( + + ), + }, + ], + [], + ); + + const columns = useMemo(() => { + const cols = baseColumns.map((col) => { + if (fixedTarget !== 'none' && col.colKey === fixedTarget) { + return { ...col, fixed: 'left' as const }; + } + return col; + }); + + if (showRowSelect) { + return [{ colKey: 'row-select', type: 'multiple', width: 46 }, ...cols]; + } + return cols; + }, [baseColumns, fixedTarget, showRowSelect]); + + const renderOrderHint = useMemo(() => { + const defineOrder = columns.map((col) => col.colKey).join(' → '); + let expectRender = defineOrder; + if (fixedColumnReorder && fixedTarget === 'name') { + expectRender = showRowSelect ? 'row-select → name → id → email → dept → ...' : 'name → id → email → dept → ...'; + } else if (fixedColumnReorder && fixedTarget === 'dept') { + expectRender = showRowSelect ? 'row-select → dept → id → name → ...' : 'dept → id → name → email → ...'; + } + return { defineOrder, expectRender }; + }, [columns, fixedColumnReorder, fixedTarget, showRowSelect]); + + return ( + + + + fixedColumnReorder(左固定列前置重排) + + + 显示多选列 + + + + setFixedTarget(val)}> + 不固定 + 固定 name(第 2 列) + 固定 dept(第 4 列) + + + + + + + + + + columns 定义顺序:{renderOrderHint.defineOrder} + + + {fixedColumnReorder ? '开启重排后预期渲染顺序' : '关闭重排(sticky 原序)'}:{renderOrderHint.expectRender} + + + 对比方式:固定 name 后切换 fixedColumnReorder 开关,观察初始是否「姓名贴左、ID 在右侧可滚」。 + + + +
+ + ); +} diff --git a/packages/components/table/hooks/useRowExpand.tsx b/packages/components/table/hooks/useRowExpand.tsx index 501d26547d..bab85d901d 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'; + // 存在任意左固定列时,展开列也需要固定(配合 fixedColumnReorder 支持非首列左固定) + 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..22a0fd64d2 100644 --- a/packages/components/table/interface.ts +++ b/packages/components/table/interface.ts @@ -15,6 +15,12 @@ import type { } from './type'; export interface BaseTableProps extends TdBaseTableProps, StyledProps { + /** + * 左固定列前置重排。开启后,将 `fixed: 'left'` 的列移到功能列(展开/多选等)之后的最左侧渲染, + * 使非首列左固定时也能初始贴左,其前面的可滚动列位于 fixed 区域右侧。默认 `false`,不影响现有表格。 + * @default false + */ + fixedColumnReorder?: boolean; /** * 渲染展开行。非公开属性,请勿在业务中使用 */ diff --git a/packages/components/table/table.md b/packages/components/table/table.md index bab334fa77..89c516828e 100644 --- a/packages/components/table/table.md +++ b/packages/components/table/table.md @@ -16,6 +16,7 @@ data | Array | [] | 数据源,泛型 T 指表格数据类型。TS 类型:`Ar disableDataPage | Boolean | false | 是否禁用本地数据分页。当 `data` 数据长度超过分页大小时,会自动进行本地数据分页。如果 `disableDataPage` 设置为 true,则无论何时,都不会进行本地数据分页 | N empty | TNode | '' | 空表格呈现样式,支持全局配置 `GlobalConfigProvider`。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N firstFullRow | TNode | - | 首行内容,横跨所有列。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N +fixedColumnReorder | Boolean | false | 左固定列前置重排。开启后,将 `fixed: 'left'` 的列移到功能列(展开/多选等)之后的最左侧渲染,使非首列左固定时也能初始贴左,其前面的可滚动列位于 fixed 区域右侧。暂不支持多级表头 | N fixedRows | Array | - | 固定行(冻结行),示例:[M, N],表示冻结表头 M 行和表尾 N 行。M 和 N 值为 0 时,表示不冻结行。TS 类型:`Array` | N footData | Array | [] | 表尾数据源,泛型 T 指表格数据类型。TS 类型:`Array` | N footerAffixProps | Object | - | 已废弃。请更为使用 `footerAffixedBottom`。表尾吸底基于 Affix 组件开发,透传全部 Affix 组件属性。。TS 类型:`Partial` | N diff --git a/packages/components/table/utils/reorderFixedColumns.ts b/packages/components/table/utils/reorderFixedColumns.ts new file mode 100644 index 0000000000..383ad5568e --- /dev/null +++ b/packages/components/table/utils/reorderFixedColumns.ts @@ -0,0 +1,61 @@ +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']); + +/** + * 判断是否存在需要前置的左固定列(非功能列、非连续从左起始的 fixed: 'left') + */ +export function hasLeftFixedColumnNeedReorder(columns: BaseTableCol[] = []): boolean { + let metScrollable = false; + for (let i = 0, len = columns.length; i < len; i++) { + const col = columns[i]; + if (col.colKey && TABLE_LEADING_COLUMN_KEYS.has(col.colKey)) continue; + if (col.fixed === 'right') break; + if (col.fixed === 'left' && metScrollable) return true; + if (!col.fixed) metScrollable = true; + } + return false; +} + +/** + * 左固定列前置重排:将所有 fixed: 'left' 的业务列移到功能列之后、可滚动区域之前。 + * 示例:[ID, name(fixed)] -> [name(fixed), ID],使 name 初始即贴左,ID 位于 fixed 右侧。 + */ +export function reorderColumnsForLeftFixed(columns: BaseTableCol[] = []): BaseTableCol[] { + if (!columns.length) return columns; + + const hasMultiHeader = columns.some((col) => col.children?.length); + if (hasMultiHeader) { + log.warn('TDesign Table', 'fixedColumnReorder 暂不支持多级表头,已跳过重排。请使用平铺列或手动调整 columns 顺序。'); + return columns; + } + + const leading: BaseTableCol[] = []; + const leftFixed: BaseTableCol[] = []; + const scrollable: BaseTableCol[] = []; + const rightFixed: BaseTableCol[] = []; + + for (let i = 0, len = columns.length; i < len; i++) { + const col = columns[i]; + if (col.colKey && TABLE_LEADING_COLUMN_KEYS.has(col.colKey)) { + leading.push(col); + continue; + } + if (col.fixed === 'right') { + rightFixed.push(col); + } else if (col.fixed === 'left') { + leftFixed.push(col); + } else { + scrollable.push(col); + } + } + + if (!hasLeftFixedColumnNeedReorder(columns)) { + return columns; + } + + return [...leading, ...leftFixed, ...scrollable, ...rightFixed]; +} From 75f18cd4f224ea5218b0deeb65b4e37a8a9e3a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Thu, 25 Jun 2026 01:55:30 +0800 Subject: [PATCH 02/10] feat(table): enhance left fixed column behavior with layout updates and reordering logic --- packages/components/table/BaseTable.tsx | 86 +++++- .../__tests__/reorderFixedColumns.test.ts | 186 ++++++++++- .../table/_example/fixed-column-reorder.tsx | 159 +++++++--- packages/components/table/hooks/useFixed.ts | 172 ++++++----- .../components/table/hooks/useRowExpand.tsx | 2 +- packages/components/table/interface.ts | 6 - packages/components/table/table.md | 1 - .../table/utils/markFixedColumnBoundaries.ts | 61 ++++ .../table/utils/reorderFixedColumns.ts | 289 +++++++++++++++++- .../utils/scheduleAfterColumnDomUpdate.ts | 17 ++ 10 files changed, 833 insertions(+), 146 deletions(-) create mode 100644 packages/components/table/utils/markFixedColumnBoundaries.ts create mode 100644 packages/components/table/utils/scheduleAfterColumnDomUpdate.ts diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 11541647af..9c618a7311 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,7 +32,8 @@ import TFoot from './TFoot'; import THead from './THead'; import { ROW_LISTENERS } from './TR'; import { getAffixProps } from './utils'; -import { reorderColumnsForLeftFixed } from './utils/reorderFixedColumns'; +import { isSameDisplayColumns, resolveLeftFixedLayout } from './utils/reorderFixedColumns'; +import { scheduleAfterColumnDomUpdate } from './utils/scheduleAfterColumnDomUpdate'; import type { RefAttributes } from 'react'; import type { AffixRef } from '../affix'; @@ -49,7 +59,6 @@ const BaseTable = forwardRef((originalProps, ref) height, data, columns: originColumns, - fixedColumnReorder, style, headerAffixedTop, bordered, @@ -58,18 +67,27 @@ const BaseTable = forwardRef((originalProps, ref) pagination, } = props; - // 左固定列前置重排,opt-in 开启,默认保持原有列顺序 - const columns = useMemo( - () => (fixedColumnReorder ? reorderColumnsForLeftFixed(originColumns) : originColumns), - [originColumns, fixedColumnReorder], - ); - const borderWidth = props.bordered ? 1 : 0; const tableRef = useRef(null); const tableElmRef = useRef(null); const bottomContentRef = useRef(null); const [tableFootHeight, setTableFootHeight] = useState(0); + const [horizontalScrollLeft, setHorizontalScrollLeft] = useState(0); + const [columns, setColumns] = useState(originColumns); + const pendingFixedRefreshRef = useRef(false); + const leftFixedReorderSignatureRef = useRef(''); + const fixedLayoutActionsRef = useRef<{ + getThWidthList: () => Record; + refreshTable: () => void; + updateColumnFixedShadow: (target: HTMLElement | null, extra?: { skipScrollLimit?: boolean }) => void; + tableContentRef: React.RefObject; + }>({ + getThWidthList: () => ({}), + refreshTable: () => undefined, + updateColumnFixedShadow: () => undefined, + tableContentRef: { current: null }, + }); const allTableClasses = useClassName(); const { classPrefix, virtualScrollClasses, tableLayoutClasses, tableBaseClass, tableColFixedClasses } = @@ -125,6 +143,7 @@ const BaseTable = forwardRef((originalProps, ref) getThWidthList, updateThWidthList, updateTableAfterColumnResize, + syncFixedColumnBorder, } = useFixed(props, finalColumns, { paginationAffixRef, horizontalScrollAffixRef, @@ -132,6 +151,50 @@ const BaseTable = forwardRef((originalProps, ref) footerBottomAffixRef, }); + fixedLayoutActionsRef.current = { + getThWidthList, + refreshTable, + updateColumnFixedShadow, + tableContentRef, + }; + + const refreshFixedOnLayoutChange = useCallback(() => { + const { + refreshTable: refresh, + updateColumnFixedShadow: updateShadow, + tableContentRef: contentRef, + } = fixedLayoutActionsRef.current; + refresh(); + updateShadow(contentRef.current, { skipScrollLimit: true }); + }, []); + + // 非首列 left fixed:scrollLeft 分阶段解析列序;重排阈值 crossing 时立即刷新 fixed + useLayoutEffect(() => { + const { getThWidthList: readColWidths, tableContentRef: contentRef } = fixedLayoutActionsRef.current; + const scrollLeft = contentRef.current?.scrollLeft ?? horizontalScrollLeft; + const layout = resolveLeftFixedLayout(originColumns, scrollLeft, readColWidths()); + const reorderChanged = leftFixedReorderSignatureRef.current !== layout.reorderSignature; + leftFixedReorderSignatureRef.current = layout.reorderSignature; + + setColumns((prev) => { + if (isSameDisplayColumns(prev, layout.displayColumns)) return prev; + pendingFixedRefreshRef.current = true; + return layout.displayColumns; + }); + + if (reorderChanged) { + pendingFixedRefreshRef.current = true; + refreshFixedOnLayoutChange(); + } + }, [originColumns, horizontalScrollLeft, refreshFixedOnLayoutChange]); + + // 列序 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); @@ -259,12 +322,17 @@ 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 (horizontalScrollLeft !== left) { + setHorizontalScrollLeft(left); + } // 排除横向滚动触发的纵向虚拟滚动计算 if (lastScrollY !== top) { virtualConfig.isVirtualScroll && virtualConfig.handleScroll(); } else { lastScrollY = -1; updateColumnFixedShadow(target); + syncFixedColumnBorder(); } lastScrollY = top; onHorizontalScroll(target); diff --git a/packages/components/table/__tests__/reorderFixedColumns.test.ts b/packages/components/table/__tests__/reorderFixedColumns.test.ts index 3ed82bf1a0..8ecdf1e348 100644 --- a/packages/components/table/__tests__/reorderFixedColumns.test.ts +++ b/packages/components/table/__tests__/reorderFixedColumns.test.ts @@ -1,9 +1,29 @@ import { describe, expect, it } from 'vitest'; -import { hasLeftFixedColumnNeedReorder, reorderColumnsForLeftFixed } from '../utils/reorderFixedColumns'; +import { + getLeftFixedBorderBoundaryColKey, + getLeftFixedReorderTriggerEntries, + getLeftFixedReorderTriggerScrollLeft, + getTriggeredLeftFixedColKeys, + hasLeftFixedColumnNeedReorder, + isLeftFixedReorderTriggered, + isSameDisplayColumns, + reorderColumnsForLeftFixed, + reorderColumnsForLeftFixedPartial, + resolveColumnsForLeftFixed, + resolveLeftFixedLayout, + shouldShowLeftFixedColumnShadow, +} 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' }, +]; + describe('reorderColumnsForLeftFixed', () => { it('非首列左固定时,将 fixed 列移到功能列之后的最左侧', () => { const columns: BaseTableCol[] = [ @@ -25,6 +45,11 @@ describe('reorderColumnsForLeftFixed', () => { 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); @@ -40,3 +65,162 @@ describe('reorderColumnsForLeftFixed', () => { 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('普通连续 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); + }); +}); diff --git a/packages/components/table/_example/fixed-column-reorder.tsx b/packages/components/table/_example/fixed-column-reorder.tsx index e2e57f7eba..bf75bb8e22 100644 --- a/packages/components/table/_example/fixed-column-reorder.tsx +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -1,9 +1,12 @@ import React, { useMemo, useRef, useState } from 'react'; import { Button, Checkbox, Radio, Space, Table, Tag } from 'tdesign-react'; -import type { PrimaryTableCol, TableProps } from 'tdesign-react'; +import { getLeftFixedReorderTriggerEntries, hasLeftFixedColumnNeedReorder } from '../utils/reorderFixedColumns'; -const data: TableProps['data'] = []; +import type { PrimaryTableRef } from '../interface'; +import type { PrimaryTableCol, TableRowData } from '../type'; + +const data: TableRowData[] = []; for (let i = 0; i < 10; i++) { data.push({ id: i + 1, @@ -16,20 +19,48 @@ for (let i = 0; i < 10; i++) { }); } -/** 固定列目标:name 为第二列数据,用于验证非首列左吸 */ -type FixedTarget = 'none' | 'name' | 'dept'; +/** 固定列场景:单非首列 / 相连两列 / 不相连两列 */ +type FixedTarget = 'none' | 'name' | 'dept' | 'connected' | 'disconnected'; + +/** 根据场景为列打上 fixed: 'left' */ +function applyFixedTarget(cols: PrimaryTableCol[], fixedTarget: FixedTarget): PrimaryTableCol[] { + const leftFixedKeys = new Set(); + if (fixedTarget === 'name') leftFixedKeys.add('name'); + if (fixedTarget === 'dept') leftFixedKeys.add('dept'); + // 相连:id + name 从左连续 fixed + if (fixedTarget === 'connected') { + leftFixedKeys.add('id'); + leftFixedKeys.add('name'); + } + // 不相连:name + dept,中间夹 email + if (fixedTarget === 'disconnected') { + leftFixedKeys.add('name'); + leftFixedKeys.add('dept'); + } + + return cols.map((col) => { + if (leftFixedKeys.has(String(col.colKey))) { + return { ...col, fixed: 'left' as const }; + } + if (col.fixed === 'left') { + const nextCol = { ...col }; + delete nextCol.fixed; + return nextCol; + } + return col; + }); +} export default function TableFixedColumnReorder() { - const tableRef = useRef(null); - const [fixedColumnReorder, setFixedColumnReorder] = useState(true); - const [fixedTarget, setFixedTarget] = useState('name'); + const tableRef = useRef(null); + const [fixedTarget, setFixedTarget] = useState('none'); const [showRowSelect, setShowRowSelect] = useState(false); const baseColumns: PrimaryTableCol[] = useMemo( () => [ { colKey: 'id', title: 'ID(定义第 1 列)', width: 100 }, { colKey: 'name', title: '姓名(定义第 2 列)', width: 120 }, - { colKey: 'email', title: '邮箱', width: 180 }, + { colKey: 'email', title: '邮箱(定义第 3 列)', width: 180 }, { colKey: 'dept', title: '部门(定义第 4 列)', width: 120 }, { colKey: 'city', title: '城市', width: 120 }, { colKey: 'address', title: '地址', width: 220 }, @@ -50,45 +81,77 @@ export default function TableFixedColumnReorder() { ); const columns = useMemo(() => { - const cols = baseColumns.map((col) => { - if (fixedTarget !== 'none' && col.colKey === fixedTarget) { - return { ...col, fixed: 'left' as const }; - } - return col; - }); - + const cols = applyFixedTarget(baseColumns, fixedTarget); if (showRowSelect) { - return [{ colKey: 'row-select', type: 'multiple', width: 46 }, ...cols]; + const rowSelectCol: PrimaryTableCol = { + colKey: 'row-select', + type: 'multiple', + width: 46, + }; + return [rowSelectCol, ...cols]; } return cols; }, [baseColumns, fixedTarget, showRowSelect]); + const willReorder = hasLeftFixedColumnNeedReorder(columns); + const renderOrderHint = useMemo(() => { const defineOrder = columns.map((col) => col.colKey).join(' → '); - let expectRender = defineOrder; - if (fixedColumnReorder && fixedTarget === 'name') { - expectRender = showRowSelect ? 'row-select → name → id → email → dept → ...' : 'name → id → email → dept → ...'; - } else if (fixedColumnReorder && fixedTarget === 'dept') { - expectRender = showRowSelect ? 'row-select → dept → id → name → ...' : 'dept → id → name → email → ...'; + const colWidths = Object.fromEntries( + baseColumns.filter((col) => col.colKey).map((col) => [col.colKey, col.width as number]), + ); + const triggerEntries = getLeftFixedReorderTriggerEntries(columns, colWidths); + const leadingPrefix = showRowSelect ? 'row-select → ' : ''; + + const stageHints: { label: string; order: string }[] = []; + if (willReorder && fixedTarget === 'name') { + stageHints.push({ + label: `name 贴左(scrollLeft ≥ ${triggerEntries[0]?.threshold ?? 0}px)`, + order: `${leadingPrefix}name → id → email → dept → ...`, + }); + } else if (willReorder && fixedTarget === 'dept') { + stageHints.push({ + label: `dept 贴左(scrollLeft ≥ ${triggerEntries[0]?.threshold ?? 0}px)`, + order: `${leadingPrefix}dept → id → name → email → ...`, + }); + } else if (fixedTarget === 'disconnected') { + triggerEntries.forEach((entry) => { + if (entry.colKey === 'name') { + stageHints.push({ + label: `name 贴左(scrollLeft ≥ ${entry.threshold}px)`, + order: `${leadingPrefix}name → id → email → dept → city → ...`, + }); + } + if (entry.colKey === 'dept') { + const nameOnlyOrder = `${leadingPrefix}name → id → email → dept → city → ...`; + const deptBorderScrollLeft = entry.threshold - (colWidths.name ?? 120); + stageHints.push({ + label: `dept border(sticky 贴住 name,scrollLeft ≥ ${deptBorderScrollLeft}px,此时尚未重排列序)`, + order: nameOnlyOrder, + }); + stageHints.push({ + label: `dept 重排前置(scrollLeft ≥ ${entry.threshold}px)`, + order: `${leadingPrefix}name → dept → id → email → city → ...`, + }); + } + }); } - return { defineOrder, expectRender }; - }, [columns, fixedColumnReorder, fixedTarget, showRowSelect]); + + return { defineOrder, stageHints }; + }, [columns, willReorder, fixedTarget, showRowSelect, baseColumns]); return ( - - - fixedColumnReorder(左固定列前置重排) - - - 显示多选列 - - + + 显示多选列 + setFixedTarget(val)}> - 不固定 + 不固定(默认列序) 固定 name(第 2 列) 固定 dept(第 4 列) + 相连两列 fixed(id + name) + 不相连两列 fixed(name + dept) @@ -98,6 +161,9 @@ export default function TableFixedColumnReorder() { + @@ -105,22 +171,29 @@ export default function TableFixedColumnReorder() { columns 定义顺序:{renderOrderHint.defineOrder} - {fixedColumnReorder ? '开启重排后预期渲染顺序' : '关闭重排(sticky 原序)'}:{renderOrderHint.expectRender} + scrollLeft=0 时渲染顺序:{renderOrderHint.defineOrder} + {fixedTarget === 'connected' && ( + + 相连 fixed:从左连续固定,不重排列序,id(left:0) + name(left:id 宽) 标准 sticky + + )} + {renderOrderHint.stageHints.map((stage) => ( + + {stage.label}:{stage.order} + + ))} + {fixedTarget === 'disconnected' && ( + + 不相连 fixed:列重排与 border 分开检测——dept sticky 贴 name 时即显示 border,重排列序仍按各自阈值 + + )} - 对比方式:固定 name 后切换 fixedColumnReorder 开关,观察初始是否「姓名贴左、ID 在右侧可滚」。 + 切换 fixed 目标会立即更新 fixed 边界;滚回贴左阈值以下或选「不固定」时 border 同步还原。 -
+
); } diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index f8be5f345d..8feb4be0e4 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -8,6 +8,8 @@ import { off, on } from '../../_util/listener'; import useDeepEffect from '../../hooks/useDeepEffect'; import usePrevious from '../../hooks/usePrevious'; import { resizeObserverElement } from '../utils'; +import { buildLeftFixedLayoutState, markFixedColumnBoundaries } from '../utils/markFixedColumnBoundaries'; +import { hasLeftFixedColumnNeedReorder, shouldShowLeftFixedColumnShadow } from '../utils/reorderFixedColumns'; import type { MutableRefObject } from 'react'; import type { AffixRef } from '../../affix'; @@ -100,6 +102,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,7 +118,10 @@ 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, }); @@ -139,6 +145,43 @@ export default function useFixed( tableElmRef.current = val; } + /** 优先读 DOM scrollLeft,避免 React state 滞后导致 fixed 边界计算错误 */ + const getCurrentHorizontalScrollLeft = () => tableContentRef.current?.scrollLeft ?? 0; + + 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 || {}; + }; + function getColumnMap( columns: BaseTableCol[], map: RowAndColFixedPosition = new Map(), @@ -239,12 +282,15 @@ 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(finalColumns, initialColumnMap); + setFixedRightPos(finalColumns, initialColumnMap); }; // 设置固定行位置信息 top/bottom @@ -267,7 +313,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,7 +334,10 @@ 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, + }); } }; @@ -299,8 +351,8 @@ export default function useFixed( 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)); }; let shadowLastScrollLeft: number; @@ -311,7 +363,7 @@ export default function useFixed( if (shadowLastScrollLeft === scrollLeft && (!extra || !extra.skipScrollLimit)) return; shadowLastScrollLeft = scrollLeft; const isShowRight = target.clientWidth + scrollLeft < target.scrollWidth; - const isShowLeft = scrollLeft > 0; + const isShowLeft = shouldShowLeftFixedColumnShadow(columns, scrollLeft, getThWidthList()); if (showColumnShadow.left === isShowLeft && showColumnShadow.right === isShowRight) return; setShowColumnShadow({ left: isShowLeft && isFixedLeftColumn, @@ -319,33 +371,36 @@ export default function useFixed( }); }; - // 多级表头场景较为复杂:为了滚动的阴影效果,需要知道哪些列是边界列,左侧固定列的最后一列,右侧固定列的第一列,每一层表头都需要兼顾 + // 多级表头场景较为复杂:为了滚动的阴影效果,需要知道哪些列是边界列 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 layout = buildLeftFixedLayoutState(columns, finalColumns, getCurrentHorizontalScrollLeft(), getThWidthList()); + markFixedColumnBoundaries(levelNodes, layout); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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 = () => { - const { newColumnsMap, levelNodes } = getColumnMap(columns); + setIsFixedColumn(false); + setIsFixedLeftColumn(false); + setIsFixedRightColumn(false); + const scrollLeft = getCurrentHorizontalScrollLeft(); + lastFixedBorderSignatureRef.current = buildLeftFixedLayoutState( + columns, + finalColumns, + scrollLeft, + getThWidthList(), + ).layoutSignature; + const { newColumnsMap, levelNodes } = getColumnMap(finalColumns); setIsLastOrFirstFixedCol(levelNodes); - if (isFixedColumn || fixedRows?.length) { + if (hasFixedColumns(finalColumns) || fixedRows?.length) { updateRowAndColFixedPosition(tableContentRef.current, newColumnsMap); + } else { + rowAndColFixedPosition.clear(); + setRowAndColFixedPosition(new Map()); } }; @@ -389,33 +444,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 +457,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 +487,7 @@ export default function useFixed( // eslint-disable-next-line react-hooks/exhaustive-deps [ data, - columns, + finalColumns, bordered, tableLayout, tableContentWidth, @@ -484,10 +504,12 @@ export default function useFixed( // eslint-disable-next-line react-hooks/exhaustive-deps useDeepEffect(() => { if (isFixedColumn) { - updateColumnFixedShadow(tableContentRef.current); + updateColumnFixedShadow(tableContentRef.current, { + skipScrollLimit: true, + }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFixedColumn, columns, tableContentRef]); + }, [isFixedColumn, finalColumns, tableContentRef]); useDeepEffect(updateFixedHeader, [maxHeight, data, columns, bordered, tableContentRef]); @@ -523,7 +545,9 @@ export default function useFixed( if (isFixedColumn || isFixedHeader) { updateFixedStatus(); - updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }); + updateColumnFixedShadow(tableContentRef.current, { + skipScrollLimit: true, + }); } }; @@ -578,6 +602,15 @@ export default function useFixed( updateFixedHeader(); }; + /** 横向滚动时同步 fixed-left-last 边界 */ + const syncFixedColumnBorder = () => { + if (!isFixedColumn || !hasLeftFixedColumnNeedReorder(columns)) return; + const layout = buildLeftFixedLayoutState(columns, finalColumns, getCurrentHorizontalScrollLeft(), getThWidthList()); + if (lastFixedBorderSignatureRef.current === layout.layoutSignature) return; + lastFixedBorderSignatureRef.current = layout.layoutSignature; + updateFixedStatus(); + }; + return { tableWidth, tableElmWidth, @@ -600,5 +633,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 bab85d901d..7a8232fbb7 100644 --- a/packages/components/table/hooks/useRowExpand.tsx +++ b/packages/components/table/hooks/useRowExpand.tsx @@ -49,7 +49,7 @@ function useRowExpand(props: TdPrimaryTableProps) { const showExpandIconColumn = props.expandIcon !== false && showExpandedRow; - // 存在任意左固定列时,展开列也需要固定(配合 fixedColumnReorder 支持非首列左固定) + // 存在任意左固定列时,展开列也需要固定(非首列 left fixed 滚动后前置) const hasLeftFixedColumn = props.columns?.some((col) => col.fixed === 'left'); const onToggleExpand = (e: MouseEvent, row: TableRowData) => { diff --git a/packages/components/table/interface.ts b/packages/components/table/interface.ts index 22a0fd64d2..6d4d509ef0 100644 --- a/packages/components/table/interface.ts +++ b/packages/components/table/interface.ts @@ -15,12 +15,6 @@ import type { } from './type'; export interface BaseTableProps extends TdBaseTableProps, StyledProps { - /** - * 左固定列前置重排。开启后,将 `fixed: 'left'` 的列移到功能列(展开/多选等)之后的最左侧渲染, - * 使非首列左固定时也能初始贴左,其前面的可滚动列位于 fixed 区域右侧。默认 `false`,不影响现有表格。 - * @default false - */ - fixedColumnReorder?: boolean; /** * 渲染展开行。非公开属性,请勿在业务中使用 */ diff --git a/packages/components/table/table.md b/packages/components/table/table.md index 89c516828e..bab334fa77 100644 --- a/packages/components/table/table.md +++ b/packages/components/table/table.md @@ -16,7 +16,6 @@ data | Array | [] | 数据源,泛型 T 指表格数据类型。TS 类型:`Ar disableDataPage | Boolean | false | 是否禁用本地数据分页。当 `data` 数据长度超过分页大小时,会自动进行本地数据分页。如果 `disableDataPage` 设置为 true,则无论何时,都不会进行本地数据分页 | N empty | TNode | '' | 空表格呈现样式,支持全局配置 `GlobalConfigProvider`。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N firstFullRow | TNode | - | 首行内容,横跨所有列。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N -fixedColumnReorder | Boolean | false | 左固定列前置重排。开启后,将 `fixed: 'left'` 的列移到功能列(展开/多选等)之后的最左侧渲染,使非首列左固定时也能初始贴左,其前面的可滚动列位于 fixed 区域右侧。暂不支持多级表头 | N fixedRows | Array | - | 固定行(冻结行),示例:[M, N],表示冻结表头 M 行和表尾 N 行。M 和 N 值为 0 时,表示不冻结行。TS 类型:`Array` | N footData | Array | [] | 表尾数据源,泛型 T 指表格数据类型。TS 类型:`Array` | N footerAffixProps | Object | - | 已废弃。请更为使用 `footerAffixedBottom`。表尾吸底基于 Affix 组件开发,透传全部 Affix 组件属性。。TS 类型:`Partial` | N diff --git a/packages/components/table/utils/markFixedColumnBoundaries.ts b/packages/components/table/utils/markFixedColumnBoundaries.ts new file mode 100644 index 0000000000..71b4031a93 --- /dev/null +++ b/packages/components/table/utils/markFixedColumnBoundaries.ts @@ -0,0 +1,61 @@ +import { resolveLeftFixedLayout } from './reorderFixedColumns'; + +import type { FixedColumnInfo } from '../interface'; +import type { BaseTableCol, TableRowData } from '../type'; +import type { LeftFixedLayoutState } 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; + } + } +} + +/** + * 根据 left fixed 布局状态标记 fixed-left-last / fixed-right-first 边界列。 + * 内置重排启用时用 sticky 边界;否则沿用「left fixed 且下一列非 left fixed」的默认规则。 + */ +export function markFixedColumnBoundaries( + levelNodes: FixedColumnInfo[][], + layout: LeftFixedLayoutState, +): 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.enabled && layout.borderBoundaryColKey) { + if (colMapInfo.col.colKey === layout.borderBoundaryColKey) { + colMapInfo.lastLeftFixedCol = true; + } + } else if (isParentLastLeftFixedCol && colMapInfo.col.fixed === 'left' && nextColMapInfo?.col.fixed !== 'left') { + colMapInfo.lastLeftFixedCol = true; + } + + if (isParentFirstRightFixedCol && colMapInfo.col.fixed === 'right' && lastColMapInfo?.col.fixed !== 'right') { + colMapInfo.firstRightFixedCol = true; + } + } + } +} + +/** 构建 useFixed 所需的 left fixed 布局快照 */ +export function buildLeftFixedLayoutState( + originColumns: BaseTableCol[], + displayColumns: BaseTableCol[], + scrollLeft: number, + colWidths: Record, +): LeftFixedLayoutState { + return resolveLeftFixedLayout(originColumns, scrollLeft, colWidths, displayColumns); +} diff --git a/packages/components/table/utils/reorderFixedColumns.ts b/packages/components/table/utils/reorderFixedColumns.ts index 383ad5568e..e103640caa 100644 --- a/packages/components/table/utils/reorderFixedColumns.ts +++ b/packages/components/table/utils/reorderFixedColumns.ts @@ -5,6 +5,55 @@ import type { BaseTableCol, TableRowData } from '../type'; /** 始终保持在最左侧的功能列,不参与左固定重排 */ export const TABLE_LEADING_COLUMN_KEYS = new Set(['__EXPAND_ROW_ICON_COLUMN__', 'row-select', 'drag', 'serial-number']); +/** 单个非首列 left fixed 的贴左触发阈值(列重排用) */ +export interface LeftFixedReorderTriggerEntry { + colKey: string; + /** 该列与容器左边界接触所需的 scrollLeft(基于 columns 定义顺序) */ + threshold: number; +} + +/** + * 非首列 left fixed 内置行为的完整布局状态。 + * - 列重排:按定义顺序独立阈值触发 + * - border:按当前渲染列序 + sticky 激活时机(可与重排阈值不同) + * - 左阴影:与重排触发一致 + */ +export interface LeftFixedLayoutState { + /** 是否存在需要内置行为的非首列 left fixed */ + enabled: boolean; + /** 当前 scrollLeft 下应渲染的 columns */ + displayColumns: BaseTableCol[]; + /** 已达重排阈值的 colKey(定义顺序) */ + reorderTriggeredKeys: string[]; + /** fixed-left-last border 应落在哪一列 */ + borderBoundaryColKey?: string; + /** 是否显示左固定列阴影 */ + showLeftShadow: boolean; + /** 重排触发签名 */ + reorderSignature: string; + /** border + 重排联合签名(滚动增量刷新用) */ + 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)); +} + /** * 判断是否存在需要前置的左固定列(非功能列、非连续从左起始的 fixed: 'left') */ @@ -12,7 +61,7 @@ export function hasLeftFixedColumnNeedReorder(columns: B let metScrollable = false; for (let i = 0, len = columns.length; i < len; i++) { const col = columns[i]; - if (col.colKey && TABLE_LEADING_COLUMN_KEYS.has(col.colKey)) continue; + if (isLeadingColumn(col)) continue; if (col.fixed === 'right') break; if (col.fixed === 'left' && metScrollable) return true; if (!col.fixed) metScrollable = true; @@ -21,41 +70,249 @@ export function hasLeftFixedColumnNeedReorder(columns: B } /** - * 左固定列前置重排:将所有 fixed: 'left' 的业务列移到功能列之后、可滚动区域之前。 - * 示例:[ID, name(fixed)] -> [name(fixed), ID],使 name 初始即贴左,ID 位于 fixed 右侧。 + * 遍历 columns 定义顺序,收集每个非首列 left fixed 的独立重排阈值。 */ -export function reorderColumnsForLeftFixed(columns: BaseTableCol[] = []): BaseTableCol[] { - if (!columns.length) return columns; +export function getLeftFixedReorderTriggerEntries( + columns: BaseTableCol[] = [], + colWidths: Record = {}, +): LeftFixedReorderTriggerEntry[] { + const entries: LeftFixedReorderTriggerEntry[] = []; + 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; +} + +/** 计算首个需重排的非首列 left fixed 贴左前的 scrollLeft 阈值 */ +export function getLeftFixedReorderTriggerScrollLeft( + columns: BaseTableCol[] = [], + colWidths: Record = {}, +): number { + const entries = getLeftFixedReorderTriggerEntries(columns, colWidths); + return entries.length ? entries[0].threshold : Infinity; +} + +/** 当前 scrollLeft 下已达重排阈值、应前置的 left fixed 列 colKey(定义顺序) */ +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('|'); +} + +/** + * 基于当前渲染列序与 sticky 激活时机,计算 fixed-left-last border 应落在哪一列。 + * 与列重排阈值分离:dept 可在 sticky 贴住 name 时即显示 border,不必等到重排阈值。 + */ +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; +} + +/** 左 fixed 重排是否已触发 */ +export function isLeftFixedReorderTriggered( + columns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): boolean { + return getTriggeredLeftFixedColKeys(columns, scrollLeft, colWidths).length > 0; +} + +/** 左固定列阴影:非首列 left fixed 时与重排触发一致,否则 scrollLeft > 0 即显示 */ +export function shouldShowLeftFixedColumnShadow( + columns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, +): boolean { + if (scrollLeft <= 0) return false; + if (hasLeftFixedColumnNeedReorder(columns)) { + return isLeftFixedReorderTriggered(columns, scrollLeft, colWidths); + } + return true; +} + +/** + * 统一解析非首列 left fixed 的布局状态(列序 / border / 阴影 / 签名)。 + * @param displayColumnsOverride 传入当前已渲染列时可跳过列序重算(如 useFixed 内已有 finalColumns) + */ +export function resolveLeftFixedLayout( + originColumns: BaseTableCol[] = [], + scrollLeft = 0, + colWidths: Record = {}, + displayColumnsOverride?: BaseTableCol[], +): LeftFixedLayoutState { + const enabled = hasLeftFixedColumnNeedReorder(originColumns); + + if (!enabled) { + return { + enabled: false, + displayColumns: originColumns, + reorderTriggeredKeys: [], + borderBoundaryColKey: undefined, + showLeftShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), + reorderSignature: '', + layoutSignature: '', + }; + } + + const reorderTriggeredKeys = getTriggeredLeftFixedColKeys(originColumns, scrollLeft, colWidths); + const displayColumns = displayColumnsOverride ?? resolveColumnsForLeftFixed(originColumns, scrollLeft, colWidths); + const borderBoundaryColKey = getLeftFixedBorderBoundaryColKey(originColumns, displayColumns, scrollLeft, colWidths); + const reorderSignature = reorderTriggeredKeys.join('|'); + const layoutSignature = `${borderBoundaryColKey ?? ''}|${reorderSignature}`; + + return { + enabled: true, + displayColumns, + reorderTriggeredKeys, + borderBoundaryColKey, + showLeftShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), + reorderSignature, + layoutSignature, + }; +} + +/** 比较两组 columns 的 colKey 顺序是否一致 */ +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; +} + +/** 列 fixed 配置签名,用于检测 fixed 目标切换 */ +export function getColumnFixedSignature(columns: BaseTableCol[] = []): string { + return columns.map((col) => `${col.colKey ?? ''}:${col.fixed ?? ''}`).join('|'); +} + +/** 渲染列配置是否一致(顺序 + fixed) */ +export function isSameDisplayColumns( + prev: BaseTableCol[] = [], + next: BaseTableCol[] = [], +): boolean { + return isSameColumnOrder(prev, next) && getColumnFixedSignature(prev) === getColumnFixedSignature(next); +} + +/** 根据 scrollLeft 与各列独立阈值解析最终渲染列顺序 */ +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)); +} + +/** 仅前置已触发的 left fixed 列;未触发的 left fixed 列保留在定义位置 */ +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', 'fixedColumnReorder 暂不支持多级表头,已跳过重排。请使用平铺列或手动调整 columns 顺序。'); + log.warn('TDesign Table', '非首列左固定暂不支持多级表头,已跳过重排。请使用平铺列或手动调整 columns 顺序。'); return columns; } const leading: BaseTableCol[] = []; - const leftFixed: BaseTableCol[] = []; + const triggeredLeftFixed: BaseTableCol[] = []; const scrollable: BaseTableCol[] = []; const rightFixed: BaseTableCol[] = []; for (let i = 0, len = columns.length; i < len; i++) { const col = columns[i]; - if (col.colKey && TABLE_LEADING_COLUMN_KEYS.has(col.colKey)) { + const colKey = String(col.colKey ?? i); + + if (isLeadingColumn(col)) { leading.push(col); continue; } if (col.fixed === 'right') { rightFixed.push(col); - } else if (col.fixed === 'left') { - leftFixed.push(col); - } else { - scrollable.push(col); + continue; + } + if (col.fixed === 'left' && triggeredColKeys.has(colKey)) { + triggeredLeftFixed.push(col); + continue; } + scrollable.push(col); } - if (!hasLeftFixedColumnNeedReorder(columns)) { - return columns; - } + return [...leading, ...triggeredLeftFixed, ...scrollable, ...rightFixed]; +} - return [...leading, ...leftFixed, ...scrollable, ...rightFixed]; +/** 全量前置(测试用):将所有需重排的 left fixed 列移到功能列之后 */ +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)); } 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; + }; +} From 9d25a5811babaadeb7ba16c32d19e94efc240e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Thu, 25 Jun 2026 02:04:34 +0800 Subject: [PATCH 03/10] feat(table): improve left fixed column layout handling with enhanced scrolling and resizing logic --- packages/components/table/BaseTable.tsx | 87 ++++++++++++++++++++----- 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 9c618a7311..3811ff5adc 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -73,19 +73,23 @@ const BaseTable = forwardRef((originalProps, ref) const tableElmRef = useRef(null); const bottomContentRef = useRef(null); const [tableFootHeight, setTableFootHeight] = useState(0); - const [horizontalScrollLeft, setHorizontalScrollLeft] = useState(0); const [columns, setColumns] = useState(originColumns); const pendingFixedRefreshRef = useRef(false); const leftFixedReorderSignatureRef = useRef(''); + const leftFixedLayoutSignatureRef = useRef(''); + const scrollLeftFixedRafRef = useRef(); + const lastScrollLeftRef = useRef(0); const fixedLayoutActionsRef = useRef<{ getThWidthList: () => Record; refreshTable: () => void; updateColumnFixedShadow: (target: HTMLElement | null, extra?: { skipScrollLimit?: boolean }) => void; + syncFixedColumnBorder: () => void; tableContentRef: React.RefObject; }>({ getThWidthList: () => ({}), refreshTable: () => undefined, updateColumnFixedShadow: () => undefined, + syncFixedColumnBorder: () => undefined, tableContentRef: { current: null }, }); @@ -155,6 +159,7 @@ const BaseTable = forwardRef((originalProps, ref) getThWidthList, refreshTable, updateColumnFixedShadow, + syncFixedColumnBorder, tableContentRef, }; @@ -168,13 +173,34 @@ const BaseTable = forwardRef((originalProps, ref) updateShadow(contentRef.current, { skipScrollLimit: true }); }, []); - // 非首列 left fixed:scrollLeft 分阶段解析列序;重排阈值 crossing 时立即刷新 fixed - useLayoutEffect(() => { - const { getThWidthList: readColWidths, tableContentRef: contentRef } = fixedLayoutActionsRef.current; - const scrollLeft = contentRef.current?.scrollLeft ?? horizontalScrollLeft; - const layout = resolveLeftFixedLayout(originColumns, scrollLeft, readColWidths()); + const resetLeftFixedLayoutSignatures = useCallback(() => { + leftFixedReorderSignatureRef.current = ''; + leftFixedLayoutSignatureRef.current = ''; + }, []); + + /** 解析 left fixed 布局:签名未变则跳过;重排走全量刷新,仅 border 变化走轻量 sync */ + const applyLeftFixedLayout = useCallback(() => { + const { + getThWidthList, + tableContentRef: contentRef, + syncFixedColumnBorder: syncBorder, + } = fixedLayoutActionsRef.current; + const scrollLeft = contentRef.current?.scrollLeft ?? 0; + const layout = resolveLeftFixedLayout(originColumns, scrollLeft, getThWidthList()); + + if (!layout.enabled) { + if (!leftFixedReorderSignatureRef.current && !leftFixedLayoutSignatureRef.current) return; + resetLeftFixedLayoutSignatures(); + setColumns((prev) => (isSameDisplayColumns(prev, originColumns) ? prev : originColumns)); + return; + } + const reorderChanged = leftFixedReorderSignatureRef.current !== layout.reorderSignature; + const layoutChanged = leftFixedLayoutSignatureRef.current !== layout.layoutSignature; + if (!reorderChanged && !layoutChanged) return; + leftFixedReorderSignatureRef.current = layout.reorderSignature; + leftFixedLayoutSignatureRef.current = layout.layoutSignature; setColumns((prev) => { if (isSameDisplayColumns(prev, layout.displayColumns)) return prev; @@ -185,8 +211,32 @@ const BaseTable = forwardRef((originalProps, ref) if (reorderChanged) { pendingFixedRefreshRef.current = true; refreshFixedOnLayoutChange(); + } else { + syncBorder(); } - }, [originColumns, horizontalScrollLeft, refreshFixedOnLayoutChange]); + }, [originColumns, refreshFixedOnLayoutChange, resetLeftFixedLayoutSignatures]); + + const scheduleScrollLeftFixedLayout = useCallback(() => { + if (scrollLeftFixedRafRef.current != null) return; + scrollLeftFixedRafRef.current = requestAnimationFrame(() => { + scrollLeftFixedRafRef.current = undefined; + applyLeftFixedLayout(); + }); + }, [applyLeftFixedLayout]); + + useEffect( + () => () => { + if (scrollLeftFixedRafRef.current != null) { + cancelAnimationFrame(scrollLeftFixedRafRef.current); + } + }, + [], + ); + + useLayoutEffect(() => { + resetLeftFixedLayoutSignatures(); + applyLeftFixedLayout(); + }, [originColumns, applyLeftFixedLayout, resetLeftFixedLayoutSignatures]); // 列序 DOM 更新后再刷新 fixed 位置 useLayoutEffect(() => { @@ -199,6 +249,12 @@ const BaseTable = forwardRef((originalProps, ref) const { dataSource, innerPagination, isPaginateData, renderPagination } = usePagination(props, tableContentRef); + const onTableAfterColumnResize = useCallback(() => { + updateTableAfterColumnResize(); + resetLeftFixedLayoutSignatures(); + applyLeftFixedLayout(); + }, [updateTableAfterColumnResize, resetLeftFixedLayoutSignatures, applyLeftFixedLayout]); + // 列宽拖拽逻辑 const columnResizeParams = useColumnResize({ isWidthOverflow, @@ -207,7 +263,7 @@ const BaseTable = forwardRef((originalProps, ref) getThWidthList, updateThWidthList, setTableElmWidth, - updateTableAfterColumnResize, + updateTableAfterColumnResize: onTableAfterColumnResize, onColumnResizeChange: props.onColumnResizeChange, }); const { resizeLineRef, resizeLineStyle, setEffectColMap, updateTableWidthOnColumnChange } = columnResizeParams; @@ -323,17 +379,18 @@ const BaseTable = forwardRef((originalProps, ref) const target = e.target as HTMLElement; const top = target.scrollTop; const left = target.scrollLeft; - if (horizontalScrollLeft !== left) { - setHorizontalScrollLeft(left); - } - // 排除横向滚动触发的纵向虚拟滚动计算 + if (lastScrollY !== top) { virtualConfig.isVirtualScroll && virtualConfig.handleScroll(); - } else { - lastScrollY = -1; + } + + // 横向滚动:rAF 合并 + 签名短路,避免每帧 setState 引发整表重渲染 + if (left !== lastScrollLeftRef.current) { + lastScrollLeftRef.current = left; + scheduleScrollLeftFixedLayout(); updateColumnFixedShadow(target); - syncFixedColumnBorder(); } + lastScrollY = top; onHorizontalScroll(target); emitScrollEvent(e); From dc089e7bf4f42c459fc2b4a54c042f0a982205bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Thu, 25 Jun 2026 02:50:18 +0800 Subject: [PATCH 04/10] feat(table): refine left fixed column reordering logic and enhance rendering hints in examples --- packages/components/table/BaseTable.tsx | 11 ++++++++--- .../table/_example/fixed-column-reorder.tsx | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 3811ff5adc..03cc3f64b0 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -32,7 +32,11 @@ import TFoot from './TFoot'; import THead from './THead'; import { ROW_LISTENERS } from './TR'; import { getAffixProps } from './utils'; -import { isSameDisplayColumns, resolveLeftFixedLayout } from './utils/reorderFixedColumns'; +import { + hasLeftFixedColumnNeedReorder, + isSameDisplayColumns, + resolveLeftFixedLayout, +} from './utils/reorderFixedColumns'; import { scheduleAfterColumnDomUpdate } from './utils/scheduleAfterColumnDomUpdate'; import type { RefAttributes } from 'react'; @@ -189,7 +193,6 @@ const BaseTable = forwardRef((originalProps, ref) const layout = resolveLeftFixedLayout(originColumns, scrollLeft, getThWidthList()); if (!layout.enabled) { - if (!leftFixedReorderSignatureRef.current && !leftFixedLayoutSignatureRef.current) return; resetLeftFixedLayoutSignatures(); setColumns((prev) => (isSameDisplayColumns(prev, originColumns) ? prev : originColumns)); return; @@ -387,7 +390,9 @@ const BaseTable = forwardRef((originalProps, ref) // 横向滚动:rAF 合并 + 签名短路,避免每帧 setState 引发整表重渲染 if (left !== lastScrollLeftRef.current) { lastScrollLeftRef.current = left; - scheduleScrollLeftFixedLayout(); + if (hasLeftFixedColumnNeedReorder(originColumns)) { + scheduleScrollLeftFixedLayout(); + } updateColumnFixedShadow(target); } diff --git a/packages/components/table/_example/fixed-column-reorder.tsx b/packages/components/table/_example/fixed-column-reorder.tsx index bf75bb8e22..4931146693 100644 --- a/packages/components/table/_example/fixed-column-reorder.tsx +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -174,9 +174,14 @@ export default function TableFixedColumnReorder() { scrollLeft=0 时渲染顺序:{renderOrderHint.defineOrder} {fixedTarget === 'connected' && ( - - 相连 fixed:从左连续固定,不重排列序,id(left:0) + name(left:id 宽) 标准 sticky - + <> + + 相连 fixed(id + name):从左连续左固定,不触发内置重排,列序始终与定义一致 + + + 标准 sticky:id 贴 left:0,name 贴 id 右侧;滚动仅显示左阴影,无分阶段前置 + + )} {renderOrderHint.stageHints.map((stage) => ( From 80948e8c56f0c80dc03f69af216b7f2238ef6263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Thu, 25 Jun 2026 19:16:41 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat(table):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=9D=9E=E8=BF=9E=E7=BB=AD=E5=B7=A6/=E5=8F=B3=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=88=97=E5=86=85=E7=BD=AE=E9=87=8D=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/table/BaseTable.tsx | 118 +++-- .../fixed-column-reorder.integration.test.tsx | 192 +++++++ .../__tests__/fixed-column-reorder.test.ts | 161 ++++++ .../markFixedColumnBoundaries.test.ts | 117 +++++ .../__tests__/reorderFixedColumns.test.ts | 215 +++++++- .../table/_example/fixed-column-reorder.tsx | 268 ++++++---- packages/components/table/hooks/useFixed.ts | 151 ++++-- .../table/utils/markFixedColumnBoundaries.ts | 50 +- .../table/utils/reorderFixedColumns.ts | 497 ++++++++++++++---- 9 files changed, 1464 insertions(+), 305 deletions(-) create mode 100644 packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx create mode 100644 packages/components/table/__tests__/fixed-column-reorder.test.ts create mode 100644 packages/components/table/__tests__/markFixedColumnBoundaries.test.ts diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 03cc3f64b0..0525020d7f 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -33,9 +33,10 @@ import THead from './THead'; import { ROW_LISTENERS } from './TR'; import { getAffixProps } from './utils'; import { - hasLeftFixedColumnNeedReorder, + createFixedLayoutScrollMetrics, + hasFixedColumnNeedReorder, isSameDisplayColumns, - resolveLeftFixedLayout, + resolveFixedColumnLayout, } from './utils/reorderFixedColumns'; import { scheduleAfterColumnDomUpdate } from './utils/scheduleAfterColumnDomUpdate'; @@ -45,7 +46,7 @@ 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'; 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); @@ -79,15 +80,19 @@ const BaseTable = forwardRef((originalProps, ref) const [tableFootHeight, setTableFootHeight] = useState(0); const [columns, setColumns] = useState(originColumns); const pendingFixedRefreshRef = useRef(false); - const leftFixedReorderSignatureRef = useRef(''); - const leftFixedLayoutSignatureRef = useRef(''); - const scrollLeftFixedRafRef = useRef(); + const fixedLayoutSignatureRef = useRef(''); + const fixedReorderSignatureRef = useRef(''); + const scrollFixedLayoutRafRef = useRef(); const lastScrollLeftRef = useRef(0); const fixedLayoutActionsRef = useRef<{ getThWidthList: () => Record; refreshTable: () => void; - updateColumnFixedShadow: (target: HTMLElement | null, extra?: { skipScrollLimit?: boolean }) => void; - syncFixedColumnBorder: () => void; + updateColumnFixedShadow: ( + target: HTMLElement | null, + extra?: { skipScrollLimit?: boolean }, + displayColumnsOverride?: BaseTableCol[], + ) => void; + syncFixedColumnBorder: (displayColumnsOverride?: BaseTableCol[]) => void; tableContentRef: React.RefObject; }>({ getThWidthList: () => ({}), @@ -152,7 +157,7 @@ const BaseTable = forwardRef((originalProps, ref) updateThWidthList, updateTableAfterColumnResize, syncFixedColumnBorder, - } = useFixed(props, finalColumns, { + } = useFixed(props, finalColumns, originColumns, { paginationAffixRef, horizontalScrollAffixRef, headerTopAffixRef, @@ -177,33 +182,38 @@ const BaseTable = forwardRef((originalProps, ref) updateShadow(contentRef.current, { skipScrollLimit: true }); }, []); - const resetLeftFixedLayoutSignatures = useCallback(() => { - leftFixedReorderSignatureRef.current = ''; - leftFixedLayoutSignatureRef.current = ''; + const resetFixedLayoutSignatures = useCallback(() => { + fixedReorderSignatureRef.current = ''; + fixedLayoutSignatureRef.current = ''; }, []); - /** 解析 left fixed 布局:签名未变则跳过;重排走全量刷新,仅 border 变化走轻量 sync */ - const applyLeftFixedLayout = useCallback(() => { - const { - getThWidthList, - tableContentRef: contentRef, - syncFixedColumnBorder: syncBorder, - } = fixedLayoutActionsRef.current; - const scrollLeft = contentRef.current?.scrollLeft ?? 0; - const layout = resolveLeftFixedLayout(originColumns, scrollLeft, getThWidthList()); + const readFixedLayoutScrollMetrics = useCallback(() => { + const el = fixedLayoutActionsRef.current.tableContentRef.current; + if (!el) return createFixedLayoutScrollMetrics(0, 0, 0); + return createFixedLayoutScrollMetrics(el.scrollLeft, el.scrollWidth, el.clientWidth); + }, []); + + /** 解析左右 fixed 布局:签名未变则跳过;重排全量刷新,仅 border 变化走轻量 sync */ + const applyFixedColumnLayout = useCallback(() => { + const { getThWidthList, syncFixedColumnBorder: syncBorder } = fixedLayoutActionsRef.current; + const scrollMetrics = readFixedLayoutScrollMetrics(); + const layout = resolveFixedColumnLayout(originColumns, scrollMetrics, getThWidthList()); if (!layout.enabled) { - resetLeftFixedLayoutSignatures(); + resetFixedLayoutSignatures(); setColumns((prev) => (isSameDisplayColumns(prev, originColumns) ? prev : originColumns)); + const { updateColumnFixedShadow: updateShadow, tableContentRef: contentRef } = fixedLayoutActionsRef.current; + updateShadow(contentRef.current, { skipScrollLimit: true }); return; } - const reorderChanged = leftFixedReorderSignatureRef.current !== layout.reorderSignature; - const layoutChanged = leftFixedLayoutSignatureRef.current !== layout.layoutSignature; + const reorderSignature = `${layout.left.reorderSignature}::${layout.right.reorderSignature}`; + const reorderChanged = fixedReorderSignatureRef.current !== reorderSignature; + const layoutChanged = fixedLayoutSignatureRef.current !== layout.layoutSignature; if (!reorderChanged && !layoutChanged) return; - leftFixedReorderSignatureRef.current = layout.reorderSignature; - leftFixedLayoutSignatureRef.current = layout.layoutSignature; + fixedReorderSignatureRef.current = reorderSignature; + fixedLayoutSignatureRef.current = layout.layoutSignature; setColumns((prev) => { if (isSameDisplayColumns(prev, layout.displayColumns)) return prev; @@ -213,33 +223,33 @@ const BaseTable = forwardRef((originalProps, ref) if (reorderChanged) { pendingFixedRefreshRef.current = true; - refreshFixedOnLayoutChange(); - } else { - syncBorder(); } - }, [originColumns, refreshFixedOnLayoutChange, resetLeftFixedLayoutSignatures]); + if (reorderChanged || layoutChanged) { + syncBorder(layout.displayColumns); + } + }, [originColumns, resetFixedLayoutSignatures, readFixedLayoutScrollMetrics]); - const scheduleScrollLeftFixedLayout = useCallback(() => { - if (scrollLeftFixedRafRef.current != null) return; - scrollLeftFixedRafRef.current = requestAnimationFrame(() => { - scrollLeftFixedRafRef.current = undefined; - applyLeftFixedLayout(); + const scheduleScrollFixedColumnLayout = useCallback(() => { + if (scrollFixedLayoutRafRef.current != null) return; + scrollFixedLayoutRafRef.current = requestAnimationFrame(() => { + scrollFixedLayoutRafRef.current = undefined; + applyFixedColumnLayout(); }); - }, [applyLeftFixedLayout]); + }, [applyFixedColumnLayout]); useEffect( () => () => { - if (scrollLeftFixedRafRef.current != null) { - cancelAnimationFrame(scrollLeftFixedRafRef.current); + if (scrollFixedLayoutRafRef.current != null) { + cancelAnimationFrame(scrollFixedLayoutRafRef.current); } }, [], ); useLayoutEffect(() => { - resetLeftFixedLayoutSignatures(); - applyLeftFixedLayout(); - }, [originColumns, applyLeftFixedLayout, resetLeftFixedLayoutSignatures]); + resetFixedLayoutSignatures(); + applyFixedColumnLayout(); + }, [originColumns, applyFixedColumnLayout, resetFixedLayoutSignatures]); // 列序 DOM 更新后再刷新 fixed 位置 useLayoutEffect(() => { @@ -254,9 +264,9 @@ const BaseTable = forwardRef((originalProps, ref) const onTableAfterColumnResize = useCallback(() => { updateTableAfterColumnResize(); - resetLeftFixedLayoutSignatures(); - applyLeftFixedLayout(); - }, [updateTableAfterColumnResize, resetLeftFixedLayoutSignatures, applyLeftFixedLayout]); + resetFixedLayoutSignatures(); + applyFixedColumnLayout(); + }, [updateTableAfterColumnResize, resetFixedLayoutSignatures, applyFixedColumnLayout]); // 列宽拖拽逻辑 const columnResizeParams = useColumnResize({ @@ -390,10 +400,24 @@ const BaseTable = forwardRef((originalProps, ref) // 横向滚动:rAF 合并 + 签名短路,避免每帧 setState 引发整表重渲染 if (left !== lastScrollLeftRef.current) { lastScrollLeftRef.current = left; - if (hasLeftFixedColumnNeedReorder(originColumns)) { - scheduleScrollLeftFixedLayout(); + if (hasFixedColumnNeedReorder(originColumns)) { + const scrollMetrics = createFixedLayoutScrollMetrics(left, target.scrollWidth, target.clientWidth); + 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); + + // 重排或 border 变化时同步标记,不等到 rAF,避免 shadow 已开但 border 列未更新的帧间闪烁 + if (layout.enabled && (reorderChanged || layoutChanged)) { + fixedLayoutActionsRef.current.syncFixedColumnBorder(layout.displayColumns); + } + + scheduleScrollFixedColumnLayout(); + } else { + updateColumnFixedShadow(target); } - updateColumnFixedShadow(target); } lastScrollY = top; 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..cc8bd4859b --- /dev/null +++ b/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx @@ -0,0 +1,192 @@ +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「右:固定 address」列配置一致 */ +const rightAddressColumns: BaseTableCol[] = [ + { colKey: 'id', title: 'ID', width: 100 }, + { colKey: 'name', title: '姓名', width: 120 }, + { colKey: 'email', title: '邮箱', width: 180 }, + { colKey: 'dept', title: '部门', width: 120 }, + { colKey: 'city', title: '城市', width: 120 }, + { colKey: 'address', title: '地址', width: 220, fixed: 'right' }, + { colKey: 'remark', title: '备注', width: 160 }, + { colKey: 'operation', title: '操作', width: 100 }, +]; + +const baseColumns: BaseTableCol[] = [ + { colKey: 'id', title: 'ID', width: 100 }, + { colKey: 'name', title: '姓名', width: 120 }, + { colKey: 'email', title: '邮箱', width: 180 }, + { colKey: 'dept', title: '部门', width: 120 }, + { colKey: 'city', title: '城市', width: 120 }, + { colKey: 'address', title: '地址', width: 220 }, + { colKey: 'remark', title: '备注', width: 160 }, + { colKey: 'operation', title: '操作', 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 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 getAddressTh(container: HTMLElement) { + return container.querySelector('th[data-colkey="address"]'); +} + +async function flushFixedLayout() { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} + +function DemoLikeTable({ fixedAddress }: { fixedAddress: boolean }) { + const columns = useMemo(() => (fixedAddress ? applyRightAddressFixed(baseColumns) : baseColumns), [fixedAddress]); + return ; +} + +describe('右固定 address 集成:border 与 shadow 同步', () => { + it('scrollLeft=0:无 fixed-right-first、无 scrollable-to-right', async () => { + const { container } = render( + , + ); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); + expect(getAddressTh(container)).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + + it('scrollLeft=140: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, 140); + + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getAddressTh(container)).toHaveClass('t-table__cell--fixed-right-first'); + }); + }); + + it('从 scroll=140 回到 0:border 与 shadow 均应清除', async () => { + const { container } = render( + , + ); + + const content = getScrollContent(container); + const tableRoot = getTableRoot(container); + mockTableScroll(content, 140); + + 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).not.toHaveClass('t-table__content--scrollable-to-right'); + expect(getAddressTh(container)).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + }); +}); + +describe('右固定 address 模式切换(复现 demo Radio 切换)', () => { + it('从「不固定」切到「右固定 address」后 scroll=0 不应提前加粗', async () => { + const { container, rerender } = render(); + await flushFixedLayout(); + + rerender(); + await flushFixedLayout(); + + const tableRoot = getTableRoot(container); + const content = getScrollContent(container); + mockTableScroll(content, 0); + await flushFixedLayout(); + + expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); + expect(getAddressTh(container)).not.toHaveClass('t-table__cell--fixed-right-first'); + }); + + it('模式切换后滚到 140 应出现 border 加粗', async () => { + const { container, rerender } = render(); + await flushFixedLayout(); + + rerender(); + await flushFixedLayout(); + + const tableRoot = getTableRoot(container); + const content = getScrollContent(container); + mockTableScroll(content, 140); + + await act(() => { + fireEvent.scroll(content, { target: content }); + }); + await flushFixedLayout(); + + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getAddressTh(container)).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..a8c3762216 --- /dev/null +++ b/packages/components/table/__tests__/fixed-column-reorder.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { + getRightFixedBorderBoundaryColKey, + getRightFixedReorderTriggerEntries, + getTriggeredRightFixedColKeys, + reorderColumnsForRightFixedPartial, + resolveColumnsForRightFixed, + shouldShowRightFixedColumnShadow, +} from '../utils/reorderFixedColumns'; + +import type { BaseTableCol } from '../type'; + +/** 与 fixed-column-reorder demo 右不相连场景一致(含 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' }, +]; + +/** 与 demo 右不相连列序一致:id,name,dept,address,city,remark,email,operation */ +const siteRightDisconnectedColumns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120 }, + { colKey: 'dept', width: 120 }, + { colKey: 'address', width: 220, fixed: 'right' }, + { colKey: 'city', width: 120 }, + { colKey: 'remark', width: 160, fixed: 'right' }, + { colKey: 'email', width: 180 }, + { colKey: 'operation', width: 100 }, +]; + +/** 与 demo「右:固定 address」一致 */ +const rightAddressColumns: BaseTableCol[] = [ + { colKey: 'id', width: 100 }, + { colKey: 'name', width: 120 }, + { colKey: 'email', width: 180 }, + { colKey: 'dept', width: 120 }, + { colKey: 'city', width: 120 }, + { colKey: 'address', width: 220, fixed: 'right' }, + { colKey: 'remark', width: 160 }, + { colKey: 'operation', width: 100 }, +]; + +const DEMO_MAX_SCROLL_LEFT = 560; +const SITE_MAX_SCROLL_LEFT = 400; +const RIGHT_ADDRESS_MAX_SCROLL_LEFT = 400; + +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:右重排阈值', () => { + it('右不相连 address 与 remark 各有独立 widthAfter', () => { + expect(getRightFixedReorderTriggerEntries(rightDisconnectedDemoColumns)).toEqual([ + { colKey: 'address', threshold: 0, widthAfter: 560 }, + { colKey: 'remark', threshold: 0, widthAfter: 280 }, + ]); + }); +}); + +describe('fixed-column-reorder demo:右 border 跟重排', () => { + it('右不相连 remark 重排后 border 在 remark', () => { + const display = reorderColumnsForRightFixedPartial(rightDisconnectedDemoColumns, new Set(['remark'])); + expect( + getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, { + scrollLeft: 200, + maxScrollLeft: 400, + }), + ).toBe('remark'); + }); + + it('address 与 remark 均重排时 border 在 display 中最靠左的已触发列', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedDemoColumns, demoScroll(420)); + expect(getTriggeredRightFixedColKeys(rightDisconnectedDemoColumns, demoScroll(420))).toEqual(['address', 'remark']); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(420))).toBe('address'); + }); + + it('address 重排后 border 仍在 address', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedDemoColumns, demoScroll(560)); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(560))).toBe('address'); + }); +}); + +describe('fixed-column-reorder demo:站点列宽右不相连', () => { + it('scroll=120 remark 达重排阈值:border 在 remark', () => { + const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(120)); + expect(getTriggeredRightFixedColKeys(siteRightDisconnectedColumns, siteScroll(120))).toEqual(['remark']); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(120))).toBe('remark'); + }); + + it('scroll=280 remark 已重排:border 在 remark(address 阈值超出 maxScrollLeft)', () => { + const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(280)); + expect(getTriggeredRightFixedColKeys(siteRightDisconnectedColumns, siteScroll(280))).toEqual(['remark']); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(280))).toBe('remark'); + }); + + it('scroll=400 滚到底:border 仍在 remark', () => { + const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(400)); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(400))).toBe('remark'); + }); +}); + +describe('fixed-column-reorder demo:右固定 address', () => { + it('scroll=140 达重排阈值:border 与阴影同时可激活', () => { + const display = resolveColumnsForRightFixed(rightAddressColumns, rightAddressScroll(140)); + expect(getTriggeredRightFixedColKeys(rightAddressColumns, rightAddressScroll(140))).toEqual(['address']); + expect(getRightFixedBorderBoundaryColKey(rightAddressColumns, display, rightAddressScroll(140))).toBe('address'); + expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(140))).toBe(true); + }); + + it('scroll=0 无 border、无阴影', () => { + expect( + getRightFixedBorderBoundaryColKey(rightAddressColumns, rightAddressColumns, rightAddressScroll(0)), + ).toBeUndefined(); + expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(0))).toBe(false); + }); +}); + +describe('fixed-column-reorder demo:阴影与 border 解耦', () => { + it('scroll=50 未重排:无 border,可有阴影', () => { + expect( + getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(50)), + ).toBeUndefined(); + expect(shouldShowRightFixedColumnShadow(siteRightDisconnectedColumns, siteScroll(50))).toBe(true); + }); + + it('scroll=280 有 border 且有阴影', () => { + const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(280)); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(280))).toBe('remark'); + expect(shouldShowRightFixedColumnShadow(siteRightDisconnectedColumns, siteScroll(280))).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..9b8f35c2c9 --- /dev/null +++ b/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts @@ -0,0 +1,117 @@ +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][3].firstRightFixedCol).toBe(false); + expect(levelNodes[0][4].firstRightFixedCol).toBe(false); + }); +}); diff --git a/packages/components/table/__tests__/reorderFixedColumns.test.ts b/packages/components/table/__tests__/reorderFixedColumns.test.ts index 8ecdf1e348..de84bd5b0f 100644 --- a/packages/components/table/__tests__/reorderFixedColumns.test.ts +++ b/packages/components/table/__tests__/reorderFixedColumns.test.ts @@ -4,15 +4,24 @@ import { getLeftFixedBorderBoundaryColKey, getLeftFixedReorderTriggerEntries, getLeftFixedReorderTriggerScrollLeft, + getRightFixedBorderBoundaryColKey, + getRightFixedReorderTriggerEntries, getTriggeredLeftFixedColKeys, + getTriggeredRightFixedColKeys, hasLeftFixedColumnNeedReorder, + hasRightFixedColumnNeedReorder, isLeftFixedReorderTriggered, isSameDisplayColumns, reorderColumnsForLeftFixed, reorderColumnsForLeftFixedPartial, + reorderColumnsForRightFixed, + reorderColumnsForRightFixedPartial, resolveColumnsForLeftFixed, + resolveColumnsForRightFixed, + resolveFixedColumnLayout, resolveLeftFixedLayout, shouldShowLeftFixedColumnShadow, + shouldShowRightFixedColumnShadow, } from '../utils/reorderFixedColumns'; import type { BaseTableCol } from '../type'; @@ -24,6 +33,21 @@ const disconnectedColumns: BaseTableCol[] = [ { 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('reorderColumnsForLeftFixed', () => { it('非首列左固定时,将 fixed 列移到功能列之后的最左侧', () => { const columns: BaseTableCol[] = [ @@ -154,13 +178,13 @@ describe('shouldShowLeftFixedColumnShadow', () => { { colKey: 'email', title: '邮箱', width: 200 }, ]; - it('非首列 left fixed 时,未达贴左阈值不显示左阴影', () => { - expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 50)).toBe(false); - expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 99)).toBe(false); + it('非首列 left fixed 时,scrollLeft > 0 即显示左阴影', () => { + expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 50)).toBe(true); + expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 99)).toBe(true); }); - it('达到贴左阈值后显示左阴影', () => { - expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 100)).toBe(true); + it('scrollLeft 为 0 时不显示左阴影', () => { + expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 0)).toBe(false); }); it('普通连续 left fixed 仍按 scrollLeft > 0 显示左阴影', () => { @@ -224,3 +248,184 @@ describe('isSameDisplayColumns', () => { 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('getRightFixedBorderBoundaryColKey', () => { + it('remark 重排后 border 在 remark', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(200)); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(200))).toBe('remark'); + }); + + it('remark 达重排阈值即显示 border(镜像左 name@150)', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(90)); + expect(getTriggeredRightFixedColKeys(rightDisconnectedColumns, rightScroll(90))).toEqual(['remark']); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(90))).toBe('remark'); + }); + + it('remark 重排后 border 仍在 remark', () => { + const display = resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(150)); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(150))).toBe('remark'); + }); + + it('未进入重排阶段时返回 undefined,内置重排时不回落默认 border', () => { + expect( + getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50)), + ).toBeUndefined(); + }); +}); + +describe('fixed border 左 sticky / 右重排', () => { + it('未重排 scroll=50:两侧均无 border', () => { + expect(getLeftFixedBorderBoundaryColKey(disconnectedColumns, disconnectedColumns, 50)).toBeUndefined(); + expect( + getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50)), + ).toBeUndefined(); + }); + + it('首列重排 scroll=150:左 name sticky;右 remark 重排', () => { + expect( + getLeftFixedBorderBoundaryColKey(disconnectedColumns, resolveColumnsForLeftFixed(disconnectedColumns, 150), 150), + ).toBe('name'); + expect( + getRightFixedBorderBoundaryColKey( + rightDisconnectedColumns, + resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(150)), + rightScroll(150), + ), + ).toBe('remark'); + }); +}); + +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('未达重排阈值时 scrollLeft > 0 仍可显示右阴影', () => { + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(50))).toBe(true); + }); + + it('scrollLeft 为 0 时不显示右阴影', () => { + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(0))).toBe(false); + }); + + it('达重排阈值时 border 在 remark,阴影由横滚决定', () => { + const display = resolveColumnsForRightFixed(columnsWithRemarkFixed, rightScroll(90)); + expect(getRightFixedBorderBoundaryColKey(columnsWithRemarkFixed, display, rightScroll(90))).toBe('remark'); + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(90))).toBe(true); + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(70))).toBe(true); + }); +}); + +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 index 4931146693..82722e83f3 100644 --- a/packages/components/table/_example/fixed-column-reorder.tsx +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -1,11 +1,75 @@ import React, { useMemo, useRef, useState } from 'react'; -import { Button, Checkbox, Radio, Space, Table, Tag } from 'tdesign-react'; - -import { getLeftFixedReorderTriggerEntries, hasLeftFixedColumnNeedReorder } from '../utils/reorderFixedColumns'; +import { Button, Radio, Space, Table, Tag } from 'tdesign-react'; import type { PrimaryTableRef } from '../interface'; import type { PrimaryTableCol, TableRowData } from '../type'; +const TABLE_CLIENT_WIDTH = 720; + +/** 固定列场景:左 / 右 对称的内置重排演示 */ +type FixedTarget = + | 'none' + | 'name' + | 'dept' + | 'connected' + | 'disconnected' + | 'rightAddress' + | 'rightDisconnected' + | 'rightConnected'; + +type StageHint = { label: string; order: string }; + +function getStageHints(fixedTarget: FixedTarget): StageHint[] { + switch (fixedTarget) { + case 'name': + return [ + { + label: 'name 贴左(scrollLeft ≥ 100px)', + order: 'name → id → email → dept → ...', + }, + ]; + case 'disconnected': + return [ + { + label: 'name 贴左(scrollLeft ≥ 100px)', + order: 'name → id → email → dept → city → ...', + }, + { + label: 'dept 重排前置(scrollLeft ≥ 400px)', + order: 'name → dept → id → email → city → ...', + }, + ]; + case 'rightAddress': + return [ + { + label: 'address 贴右(scrollLeft ≥ 140px)', + order: '... → address → remark → operation', + }, + ]; + case 'rightDisconnected': + return [ + { + label: '定义结构(镜像左不相连)', + order: 'address(fixed) → city → remark(fixed) → email → operation', + }, + { + label: 'remark 贴右后置(scrollLeft ≥ 120px)', + order: '... → remark → operation', + }, + ]; + default: + return []; + } +} + +function needsReorderHint(fixedTarget: FixedTarget): boolean { + return ['name', 'dept', 'disconnected', 'rightAddress', 'rightDisconnected'].includes(fixedTarget); +} + +function needsRightReorderHint(fixedTarget: FixedTarget): boolean { + return fixedTarget === 'rightAddress' || fixedTarget === 'rightDisconnected'; +} + const data: TableRowData[] = []; for (let i = 0; i < 10; i++) { data.push({ @@ -19,42 +83,63 @@ for (let i = 0; i < 10; i++) { }); } -/** 固定列场景:单非首列 / 相连两列 / 不相连两列 */ -type FixedTarget = 'none' | 'name' | 'dept' | 'connected' | 'disconnected'; - -/** 根据场景为列打上 fixed: 'left' */ +/** 根据场景为列设置 fixed,并在右不相连时重排列序(镜像左:name—email—dept) */ function applyFixedTarget(cols: PrimaryTableCol[], fixedTarget: FixedTarget): PrimaryTableCol[] { const leftFixedKeys = new Set(); + const rightFixedKeys = new Set(); + if (fixedTarget === 'name') leftFixedKeys.add('name'); if (fixedTarget === 'dept') leftFixedKeys.add('dept'); - // 相连:id + name 从左连续 fixed if (fixedTarget === 'connected') { leftFixedKeys.add('id'); leftFixedKeys.add('name'); } - // 不相连:name + dept,中间夹 email if (fixedTarget === 'disconnected') { leftFixedKeys.add('name'); leftFixedKeys.add('dept'); } + if (fixedTarget === 'rightAddress') rightFixedKeys.add('address'); + if (fixedTarget === 'rightDisconnected') { + rightFixedKeys.add('address'); + rightFixedKeys.add('remark'); + } + if (fixedTarget === 'rightConnected') { + rightFixedKeys.add('remark'); + rightFixedKeys.add('operation'); + } - return cols.map((col) => { - if (leftFixedKeys.has(String(col.colKey))) { - return { ...col, fixed: 'left' as const }; - } - if (col.fixed === 'left') { + const colMap = Object.fromEntries(cols.map((col) => [String(col.colKey), col])); + + const attachFixed = (col: PrimaryTableCol): PrimaryTableCol => { + const key = String(col.colKey); + if (leftFixedKeys.has(key)) return { ...col, fixed: 'left' as const }; + if (rightFixedKeys.has(key)) return { ...col, fixed: 'right' as const }; + if (col.fixed === 'left' || col.fixed === 'right') { const nextCol = { ...col }; delete nextCol.fixed; return nextCol; } return col; - }); + }; + + // 右不相连:address(R) — city — remark(R) — email — operation(镜像左:仅 name、dept 左固定) + if (fixedTarget === 'rightDisconnected') { + const orderedKeys = ['id', 'name', 'dept', 'address', 'city', 'remark', 'email', 'operation']; + return orderedKeys.map((key) => attachFixed(colMap[key])).filter(Boolean); + } + + // 右相连:remark + operation 贴右连续 fixed + if (fixedTarget === 'rightConnected') { + const orderedKeys = ['id', 'name', 'email', 'dept', 'city', 'address', 'remark', 'operation']; + return orderedKeys.map((key) => attachFixed(colMap[key])).filter(Boolean); + } + + return cols.map((col) => attachFixed(col)); } export default function TableFixedColumnReorder() { const tableRef = useRef(null); const [fixedTarget, setFixedTarget] = useState('none'); - const [showRowSelect, setShowRowSelect] = useState(false); const baseColumns: PrimaryTableCol[] = useMemo( () => [ @@ -69,7 +154,6 @@ export default function TableFixedColumnReorder() { colKey: 'operation', title: '操作', width: 100, - fixed: 'right', cell: () => ( - - columns 定义顺序:{renderOrderHint.defineOrder} + 定义顺序:{renderOrderHint.defineOrder} - scrollLeft=0 时渲染顺序:{renderOrderHint.defineOrder} + scrollLeft=0 渲染顺序与定义一致 {fixedTarget === 'connected' && ( - <> - - 相连 fixed(id + name):从左连续左固定,不触发内置重排,列序始终与定义一致 - - - 标准 sticky:id 贴 left:0,name 贴 id 右侧;滚动仅显示左阴影,无分阶段前置 - - + + 从左连续 left fixed,不触发重排 + + )} + {fixedTarget === 'rightConnected' && ( + + 从右连续 right fixed,不触发重排 + + )} + {fixedTarget === 'rightDisconnected' && ( + + 仅 address、remark 右固定(镜像左 name+dept);city、email 可滚动,operation 贴末列不固定 + + )} + {needsReorderHint(fixedTarget) && ( + + 预估 maxScrollLeft ≈ {maxScrollLeft}px(表格宽 {TABLE_CLIENT_WIDTH} + px) + + )} + {needsRightReorderHint(fixedTarget) && ( + + 右侧 border 在重排阈值后出现;加粗还需横滚(scrollLeft > 0 且未滚到底) + )} {renderOrderHint.stageHints.map((stage) => ( {stage.label}:{stage.order} ))} - {fixedTarget === 'disconnected' && ( - - 不相连 fixed:列重排与 border 分开检测——dept sticky 贴 name 时即显示 border,重排列序仍按各自阈值 - - )} - - 切换 fixed 目标会立即更新 fixed 边界;滚回贴左阈值以下或选「不固定」时 border 同步还原。 - -
+
); } diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index 8feb4be0e4..7ef5b1fbf1 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -8,8 +8,14 @@ import { off, on } from '../../_util/listener'; import useDeepEffect from '../../hooks/useDeepEffect'; import usePrevious from '../../hooks/usePrevious'; import { resizeObserverElement } from '../utils'; -import { buildLeftFixedLayoutState, markFixedColumnBoundaries } from '../utils/markFixedColumnBoundaries'; -import { hasLeftFixedColumnNeedReorder, shouldShowLeftFixedColumnShadow } from '../utils/reorderFixedColumns'; +import { buildFixedLayoutState, markFixedColumnBoundaries } from '../utils/markFixedColumnBoundaries'; +import { + createFixedLayoutScrollMetrics, + hasFixedColumnNeedReorder, + resolveDisplayColumnsForFixed, + shouldShowLeftFixedColumnShadow, + shouldShowRightFixedColumnShadow, +} from '../utils/reorderFixedColumns'; import type { MutableRefObject } from 'react'; import type { AffixRef } from '../../affix'; @@ -77,6 +83,7 @@ export function getRowFixedStyles( export default function useFixed( props: TdBaseTableProps, finalColumns: BaseTableCol[], + originColumns: BaseTableCol[] = finalColumns, affixRef?: { paginationAffixRef: MutableRefObject; horizontalScrollAffixRef: MutableRefObject; @@ -126,8 +133,6 @@ export default function useFixed( top: 0, }); const [isFixedColumn, setIsFixedColumn] = useState(false); - const [isFixedRightColumn, setIsFixedRightColumn] = useState(false); - const [isFixedLeftColumn, setIsFixedLeftColumn] = useState(false); // 没有表头吸顶,没有虚拟滚动,则不需要表头宽度计算 const notNeedThWidthList = useMemo( @@ -145,8 +150,11 @@ export default function useFixed( tableElmRef.current = val; } - /** 优先读 DOM scrollLeft,避免 React state 滞后导致 fixed 边界计算错误 */ - const getCurrentHorizontalScrollLeft = () => tableContentRef.current?.scrollLeft ?? 0; + const getFixedLayoutScrollMetrics = () => { + const el = tableContentRef.current; + if (!el) return createFixedLayoutScrollMetrics(0, 0, 0); + return createFixedLayoutScrollMetrics(el.scrollLeft, el.scrollWidth, el.clientWidth); + }; const calculateThWidthList = (trList: HTMLCollection) => { const widthMap: { [colKey: string]: number } = {}; @@ -182,6 +190,15 @@ export default function useFixed( 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(), @@ -194,12 +211,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); @@ -270,7 +281,11 @@ export default function useFixed( }; // 获取固定列位置信息。先获取节点宽度,再计算 - 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; @@ -289,8 +304,8 @@ export default function useFixed( } } } - setFixedLeftPos(finalColumns, initialColumnMap); - setFixedRightPos(finalColumns, initialColumnMap); + setFixedLeftPos(displayColumns, initialColumnMap); + setFixedRightPos(displayColumns, initialColumnMap); }; // 设置固定行位置信息 top/bottom @@ -341,12 +356,18 @@ export default function useFixed( } }; - 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'); @@ -355,6 +376,38 @@ export default function useFixed( 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, + }); + } 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 }) => { if (!isFixedColumn || !target) return; @@ -362,18 +415,23 @@ export default function useFixed( // 只有左右滚动,需要更新固定列阴影 if (shadowLastScrollLeft === scrollLeft && (!extra || !extra.skipScrollLimit)) return; shadowLastScrollLeft = scrollLeft; - const isShowRight = target.clientWidth + scrollLeft < target.scrollWidth; - const isShowLeft = shouldShowLeftFixedColumnShadow(columns, scrollLeft, getThWidthList()); + const scrollMetrics = createFixedLayoutScrollMetrics(scrollLeft, target.scrollWidth, target.clientWidth); + const isShowRight = shouldShowRightFixedColumnShadow(originColumns, scrollMetrics); + const isShowLeft = shouldShowLeftFixedColumnShadow(originColumns, scrollLeft); 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[][]) => { - const layout = buildLeftFixedLayoutState(columns, finalColumns, getCurrentHorizontalScrollLeft(), getThWidthList()); + const setIsLastOrFirstFixedCol = ( + levelNodes: FixedColumnInfo[][], + displayColumns: BaseTableCol[] = finalColumns, + scrollMetrics = getFixedLayoutScrollMetrics(), + ) => { + const layout = buildFixedLayoutState(originColumns, displayColumns, scrollMetrics, getThWidthList()); markFixedColumnBoundaries(levelNodes, layout); }; @@ -383,25 +441,25 @@ export default function useFixed( return col.children?.length ? hasFixedColumns(col.children) : false; }); - const updateFixedStatus = () => { + const updateFixedStatus = (displayColumnsOverride?: BaseTableCol[]) => { setIsFixedColumn(false); - setIsFixedLeftColumn(false); - setIsFixedRightColumn(false); - const scrollLeft = getCurrentHorizontalScrollLeft(); - lastFixedBorderSignatureRef.current = buildLeftFixedLayoutState( - columns, - finalColumns, - scrollLeft, + const scrollMetrics = getFixedLayoutScrollMetrics(); + const displayColumns = resolveDisplayColumns(displayColumnsOverride); + lastFixedBorderSignatureRef.current = buildFixedLayoutState( + originColumns, + displayColumns, + scrollMetrics, getThWidthList(), ).layoutSignature; - const { newColumnsMap, levelNodes } = getColumnMap(finalColumns); - setIsLastOrFirstFixedCol(levelNodes); - if (hasFixedColumns(finalColumns) || fixedRows?.length) { - updateRowAndColFixedPosition(tableContentRef.current, newColumnsMap); + const { newColumnsMap, levelNodes } = getColumnMap(displayColumns); + setIsLastOrFirstFixedCol(levelNodes, displayColumns, scrollMetrics); + if (hasFixedColumns(displayColumns) || fixedRows?.length) { + updateRowAndColFixedPosition(tableContentRef.current, newColumnsMap, displayColumns); + setRowAndColFixedPosition((prev) => applyFixedBoundaryFlagsToMap(prev.size ? prev : newColumnsMap, levelNodes)); } else { - rowAndColFixedPosition.clear(); setRowAndColFixedPosition(new Map()); } + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }); }; // 使用 useCallback 来优化性能 @@ -602,13 +660,20 @@ export default function useFixed( updateFixedHeader(); }; - /** 横向滚动时同步 fixed-left-last 边界 */ - const syncFixedColumnBorder = () => { - if (!isFixedColumn || !hasLeftFixedColumnNeedReorder(columns)) return; - const layout = buildLeftFixedLayoutState(columns, finalColumns, getCurrentHorizontalScrollLeft(), getThWidthList()); + /** 横向滚动时同步 fixed 边界(左 last / 右 first),仅更新 border 标记与阴影,避免列序 DOM 未更新时重算 sticky */ + const syncFixedColumnBorder = (displayColumnsOverride?: BaseTableCol[]) => { + if (!hasFixedColumnNeedReorder(originColumns)) return; + const scrollMetrics = getFixedLayoutScrollMetrics(); + const displayColumns = resolveDisplayColumns(displayColumnsOverride); + const layout = buildFixedLayoutState(originColumns, displayColumns, scrollMetrics, getThWidthList()); if (lastFixedBorderSignatureRef.current === layout.layoutSignature) return; lastFixedBorderSignatureRef.current = layout.layoutSignature; - updateFixedStatus(); + + const { newColumnsMap, levelNodes } = getColumnMap(displayColumns); + setIsLastOrFirstFixedCol(levelNodes, displayColumns, scrollMetrics); + setRowAndColFixedPosition((prev) => applyFixedBoundaryFlagsToMap(prev.size ? prev : newColumnsMap, levelNodes)); + + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }); }; return { diff --git a/packages/components/table/utils/markFixedColumnBoundaries.ts b/packages/components/table/utils/markFixedColumnBoundaries.ts index 71b4031a93..08f1f72dc3 100644 --- a/packages/components/table/utils/markFixedColumnBoundaries.ts +++ b/packages/components/table/utils/markFixedColumnBoundaries.ts @@ -1,8 +1,8 @@ -import { resolveLeftFixedLayout } from './reorderFixedColumns'; +import { resolveFixedColumnLayout } from './reorderFixedColumns'; import type { FixedColumnInfo } from '../interface'; import type { BaseTableCol, TableRowData } from '../type'; -import type { LeftFixedLayoutState } from './reorderFixedColumns'; +import type { FixedColumnLayoutState, FixedLayoutScrollMetrics } from './reorderFixedColumns'; /** 重置 fixed 边界标记 */ function resetFixedBoundaryFlags(levelNodes: FixedColumnInfo[][]): void { @@ -16,12 +16,12 @@ function resetFixedBoundaryFlags(levelNodes: FixedColumnInfo[][]): void { } /** - * 根据 left fixed 布局状态标记 fixed-left-last / fixed-right-first 边界列。 - * 内置重排启用时用 sticky 边界;否则沿用「left fixed 且下一列非 left fixed」的默认规则。 + * 标记 fixed-left-last / fixed-right-first 边界列。 + * 左 border:sticky 几何;右 border:重排阈值。阴影 gate 仅反映横滚(见 shouldShow*FixedColumnShadow)。 */ export function markFixedColumnBoundaries( levelNodes: FixedColumnInfo[][], - layout: LeftFixedLayoutState, + layout: FixedColumnLayoutState, ): void { resetFixedBoundaryFlags(levelNodes); @@ -35,27 +35,53 @@ export function markFixedColumnBoundaries( const isParentLastLeftFixedCol = !parent || parent?.lastLeftFixedCol; const isParentFirstRightFixedCol = !parent || parent?.firstRightFixedCol; - if (layout.enabled && layout.borderBoundaryColKey) { - if (colMapInfo.col.colKey === layout.borderBoundaryColKey) { + // 内置重排:左 border 为 sticky 边界,右 border 为重排阈值;启用时禁止回落默认规则 + 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') { + } else if ( + !layout.enabled && + isParentLastLeftFixedCol && + colMapInfo.col.fixed === 'left' && + nextColMapInfo?.col.fixed !== 'left' + ) { colMapInfo.lastLeftFixedCol = true; } - if (isParentFirstRightFixedCol && colMapInfo.col.fixed === 'right' && lastColMapInfo?.col.fixed !== 'right') { + if (layout.right.enabled) { + if (layout.right.borderBoundaryColKey && colMapInfo.col.colKey === layout.right.borderBoundaryColKey) { + colMapInfo.firstRightFixedCol = true; + } + } else if ( + !layout.enabled && + layout.right.showShadow && + isParentFirstRightFixedCol && + colMapInfo.col.fixed === 'right' && + lastColMapInfo?.col.fixed !== 'right' + ) { colMapInfo.firstRightFixedCol = true; } } } } -/** 构建 useFixed 所需的 left fixed 布局快照 */ +/** 构建 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, -): LeftFixedLayoutState { - return resolveLeftFixedLayout(originColumns, scrollLeft, colWidths, displayColumns); +): 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 index e103640caa..f58305a237 100644 --- a/packages/components/table/utils/reorderFixedColumns.ts +++ b/packages/components/table/utils/reorderFixedColumns.ts @@ -5,33 +5,50 @@ import type { BaseTableCol, TableRowData } from '../type'; /** 始终保持在最左侧的功能列,不参与左固定重排 */ export const TABLE_LEADING_COLUMN_KEYS = new Set(['__EXPAND_ROW_ICON_COLUMN__', 'row-select', 'drag', 'serial-number']); -/** 单个非首列 left fixed 的贴左触发阈值(列重排用) */ -export interface LeftFixedReorderTriggerEntry { +/** 横向滚动度量(右侧贴边阈值依赖 maxScrollLeft) */ +export interface FixedLayoutScrollMetrics { + scrollLeft: number; + maxScrollLeft: number; +} + +/** 单侧 fixed 重排触发项 */ +export interface FixedReorderTriggerEntry { colKey: string; - /** 该列与容器左边界接触所需的 scrollLeft(基于 columns 定义顺序) */ + /** 左:scrollLeft >= threshold;右:scrollLeft >= maxScrollLeft - widthAfter */ threshold: number; + widthAfter?: number; } -/** - * 非首列 left fixed 内置行为的完整布局状态。 - * - 列重排:按定义顺序独立阈值触发 - * - border:按当前渲染列序 + sticky 激活时机(可与重排阈值不同) - * - 左阴影:与重排触发一致 - */ +/** 单侧 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 { - /** 是否存在需要内置行为的非首列 left fixed */ enabled: boolean; - /** 当前 scrollLeft 下应渲染的 columns */ displayColumns: BaseTableCol[]; - /** 已达重排阈值的 colKey(定义顺序) */ reorderTriggeredKeys: string[]; - /** fixed-left-last border 应落在哪一列 */ borderBoundaryColKey?: string; - /** 是否显示左固定列阴影 */ showLeftShadow: boolean; - /** 重排触发签名 */ reorderSignature: string; - /** border + 重排联合签名(滚动增量刷新用) */ layoutSignature: string; } @@ -54,9 +71,45 @@ function isLeadingColumn(col: BaseTableCol): boolean return Boolean(col.colKey && TABLE_LEADING_COLUMN_KEYS.has(col.colKey)); } -/** - * 判断是否存在需要前置的左固定列(非功能列、非连续从左起始的 fixed: 'left') - */ +/** 在 displayColumns 中取最靠左、且已触发重排的 right fixed 列 */ +function findLeftmostTriggeredRightFixedColKey( + displayColumns: BaseTableCol[] = [], + triggeredColKeys: Set = new Set(), +): string | undefined { + for (let i = 0, len = displayColumns.length; i < len; i++) { + const col = displayColumns[i]; + const colKey = String(col.colKey ?? i); + if (col.fixed === 'right' && triggeredColKeys.has(colKey)) { + return colKey; + } + } + return undefined; +} + +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 ---------- + export function hasLeftFixedColumnNeedReorder(columns: BaseTableCol[] = []): boolean { let metScrollable = false; for (let i = 0, len = columns.length; i < len; i++) { @@ -69,14 +122,11 @@ export function hasLeftFixedColumnNeedReorder(columns: B return false; } -/** - * 遍历 columns 定义顺序,收集每个非首列 left fixed 的独立重排阈值。 - */ export function getLeftFixedReorderTriggerEntries( columns: BaseTableCol[] = [], colWidths: Record = {}, -): LeftFixedReorderTriggerEntry[] { - const entries: LeftFixedReorderTriggerEntry[] = []; +): FixedReorderTriggerEntry[] { + const entries: FixedReorderTriggerEntry[] = []; let widthBefore = 0; let metScrollable = false; @@ -99,7 +149,6 @@ export function getLeftFixedReorderTriggerEntries( return entries; } -/** 计算首个需重排的非首列 left fixed 贴左前的 scrollLeft 阈值 */ export function getLeftFixedReorderTriggerScrollLeft( columns: BaseTableCol[] = [], colWidths: Record = {}, @@ -108,7 +157,6 @@ export function getLeftFixedReorderTriggerScrollLeft( return entries.length ? entries[0].threshold : Infinity; } -/** 当前 scrollLeft 下已达重排阈值、应前置的 left fixed 列 colKey(定义顺序) */ export function getTriggeredLeftFixedColKeys( columns: BaseTableCol[] = [], scrollLeft = 0, @@ -128,10 +176,6 @@ export function getTriggeredLeftFixedSignature( return getTriggeredLeftFixedColKeys(columns, scrollLeft, colWidths).join('|'); } -/** - * 基于当前渲染列序与 sticky 激活时机,计算 fixed-left-last border 应落在哪一列。 - * 与列重排阈值分离:dept 可在 sticky 贴住 name 时即显示 border,不必等到重排阈值。 - */ export function getLeftFixedBorderBoundaryColKey( originColumns: BaseTableCol[] = [], displayColumns: BaseTableCol[] = [], @@ -170,7 +214,27 @@ export function getLeftFixedBorderBoundaryColKey( return lastStickyActiveKey; } -/** 左 fixed 重排是否已触发 */ +/** + * 右侧 fixed border 边界(fixed-right-first)。 + * 内置重排:跟重排阈值,取 display 中最靠左的已触发 right fixed 列。 + */ +export function getRightFixedBorderBoundaryColKey( + originColumns: BaseTableCol[] = [], + displayColumns: BaseTableCol[] = [], + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, +): string | undefined { + const { scrollLeft } = scrollMetrics; + if (scrollLeft <= 0 || !hasRightFixedColumnNeedReorder(originColumns)) { + return undefined; + } + + const triggeredKeys = new Set(getTriggeredRightFixedColKeys(originColumns, scrollMetrics, colWidths)); + if (!triggeredKeys.size) return undefined; + + return findLeftmostTriggeredRightFixedColKey(displayColumns, triggeredKeys); +} + export function isLeftFixedReorderTriggered( columns: BaseTableCol[] = [], scrollLeft = 0, @@ -179,99 +243,218 @@ export function isLeftFixedReorderTriggered( return getTriggeredLeftFixedColKeys(columns, scrollLeft, colWidths).length > 0; } -/** 左固定列阴影:非首列 left fixed 时与重排触发一致,否则 scrollLeft > 0 即显示 */ export function shouldShowLeftFixedColumnShadow( columns: BaseTableCol[] = [], scrollLeft = 0, - colWidths: Record = {}, ): boolean { if (scrollLeft <= 0) return false; if (hasLeftFixedColumnNeedReorder(columns)) { - return isLeftFixedReorderTriggered(columns, scrollLeft, colWidths); + // 内置重排:阴影仅表示「已发生横滚」,与 border 列(sticky)解耦;加粗需二者同时满足 + return true; } return true; } -/** - * 统一解析非首列 left fixed 的布局状态(列序 / border / 阴影 / 签名)。 - * @param displayColumnsOverride 传入当前已渲染列时可跳过列序重算(如 useFixed 内已有 finalColumns) - */ -export function resolveLeftFixedLayout( - originColumns: BaseTableCol[] = [], +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 = {}, - displayColumnsOverride?: BaseTableCol[], -): LeftFixedLayoutState { +): 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, - displayColumns: originColumns, reorderTriggeredKeys: [], borderBoundaryColKey: undefined, - showLeftShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), + showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft), reorderSignature: '', - layoutSignature: '', + sideLayoutSignature: '', }; } const reorderTriggeredKeys = getTriggeredLeftFixedColKeys(originColumns, scrollLeft, colWidths); - const displayColumns = displayColumnsOverride ?? resolveColumnsForLeftFixed(originColumns, scrollLeft, colWidths); const borderBoundaryColKey = getLeftFixedBorderBoundaryColKey(originColumns, displayColumns, scrollLeft, colWidths); const reorderSignature = reorderTriggeredKeys.join('|'); - const layoutSignature = `${borderBoundaryColKey ?? ''}|${reorderSignature}`; return { enabled: true, - displayColumns, reorderTriggeredKeys, borderBoundaryColKey, - showLeftShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), + showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft), reorderSignature, - layoutSignature, + sideLayoutSignature: `${borderBoundaryColKey ?? ''}|${reorderSignature}`, }; } -/** 比较两组 columns 的 colKey 顺序是否一致 */ -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; +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 true; + return false; } -/** 列 fixed 配置签名,用于检测 fixed 目标切换 */ -export function getColumnFixedSignature(columns: BaseTableCol[] = []): string { - return columns.map((col) => `${col.colKey ?? ''}:${col.fixed ?? ''}`).join('|'); +export function hasFixedColumnNeedReorder(columns: BaseTableCol[] = []): boolean { + return hasLeftFixedColumnNeedReorder(columns) || hasRightFixedColumnNeedReorder(columns); } -/** 渲染列配置是否一致(顺序 + fixed) */ -export function isSameDisplayColumns( - prev: BaseTableCol[] = [], - next: BaseTableCol[] = [], -): boolean { - return isSameColumnOrder(prev, next) && getColumnFixedSignature(prev) === getColumnFixedSignature(next); +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; } -/** 根据 scrollLeft 与各列独立阈值解析最终渲染列顺序 */ -export function resolveColumnsForLeftFixed( +export function getTriggeredRightFixedColKeys( columns: BaseTableCol[] = [], - scrollLeft = 0, + scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 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)); +): string[] { + const { scrollLeft, maxScrollLeft } = scrollMetrics; + if (maxScrollLeft <= 0 || !hasRightFixedColumnNeedReorder(columns)) return []; + + return getRightFixedReorderTriggerEntries(columns, colWidths) + .filter((entry) => { + const widthAfter = entry.widthAfter ?? 0; + const threshold = maxScrollLeft - widthAfter; + return threshold >= 0 && scrollLeft >= threshold; + }) + .map((entry) => entry.colKey); } -/** 仅前置已触发的 left fixed 列;未触发的 left fixed 列保留在定义位置 */ -export function reorderColumnsForLeftFixedPartial( +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 }, +): boolean { + const { scrollLeft, maxScrollLeft } = scrollMetrics; + if (maxScrollLeft <= 0 || scrollLeft <= 0) return false; + if (hasRightFixedColumnNeedReorder(columns)) { + // 内置重排:阴影仅表示「仍可向右滚动」,与 border 列(重排阈值)解耦;加粗需二者同时满足 + return scrollLeft < maxScrollLeft; + } + return scrollLeft < maxScrollLeft; +} + +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[] { @@ -279,40 +462,164 @@ export function reorderColumnsForLeftFixedPartial( const hasMultiHeader = columns.some((col) => col.children?.length); if (hasMultiHeader) { - log.warn('TDesign Table', '非首列左固定暂不支持多级表头,已跳过重排。请使用平铺列或手动调整 columns 顺序。'); + 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 triggeredLeftFixed: BaseTableCol[] = []; - const scrollable: BaseTableCol[] = []; - const rightFixed: BaseTableCol[] = []; + const leftFixed: BaseTableCol[] = []; + const body: BaseTableCol[] = []; + const triggeredRightFixed: BaseTableCol[] = []; - for (let i = 0, len = columns.length; i < len; i++) { - const col = columns[i]; + 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 === 'right') { - rightFixed.push(col); + if (col.fixed === 'left') { + leftFixed.push(col); continue; } - if (col.fixed === 'left' && triggeredColKeys.has(colKey)) { - triggeredLeftFixed.push(col); + if (col.fixed === 'right') { + if (trailingKeys.has(colKey)) continue; + if (triggeredColKeys.has(colKey)) triggeredRightFixed.push(col); + else body.push(col); continue; } - scrollable.push(col); + body.push(col); } - return [...leading, ...triggeredLeftFixed, ...scrollable, ...rightFixed]; + return [...leading, ...leftFixed, ...body, ...triggeredRightFixed, ...trailingRightFixed, ...absoluteTail]; } -/** 全量前置(测试用):将所有需重排的 left fixed 列移到功能列之后 */ -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)); +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), + 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), + 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); } From 178fc9336cccfbebc3500ca1137dfd93507b9d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Fri, 26 Jun 2026 02:48:26 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20fixed=20right?= =?UTF-8?q?=20=E7=9A=84=20border=20=E4=B8=8D=E5=90=88=E7=90=86=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/table/BaseTable.tsx | 2 +- .../fixed-column-reorder.integration.test.tsx | 249 +++++++-- .../__tests__/fixed-column-reorder.test.ts | 183 ++++--- .../__tests__/reorderFixedColumns.test.ts | 111 +++-- .../table/_example/fixed-column-reorder.tsx | 471 ++++++++++-------- packages/components/table/hooks/useFixed.ts | 112 +++-- packages/components/table/interface.ts | 2 + .../table/utils/markFixedColumnBoundaries.ts | 4 +- .../table/utils/reorderFixedColumns.ts | 281 +++++++++-- .../site/src/components/Demo.jsx | 2 +- 10 files changed, 1004 insertions(+), 413 deletions(-) diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index 0525020d7f..c393783a39 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -407,7 +407,7 @@ const BaseTable = forwardRef((originalProps, ref) const reorderChanged = fixedReorderSignatureRef.current !== reorderSignature; const layoutChanged = fixedLayoutSignatureRef.current !== layout.layoutSignature; - updateColumnFixedShadow(target); + updateColumnFixedShadow(target, undefined, layout.displayColumns); // 重排或 border 变化时同步标记,不等到 rAF,避免 shadow 已开但 border 列未更新的帧间闪烁 if (layout.enabled && (reorderChanged || layoutChanged)) { diff --git a/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx b/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx index cc8bd4859b..c31be7bf11 100644 --- a/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx +++ b/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx @@ -6,27 +6,16 @@ import BaseTable from '../BaseTable'; import type { BaseTableCol, TableRowData } from '../type'; -/** 与 demo「右:固定 address」列配置一致 */ -const rightAddressColumns: BaseTableCol[] = [ - { colKey: 'id', title: 'ID', width: 100 }, - { colKey: 'name', title: '姓名', width: 120 }, - { colKey: 'email', title: '邮箱', width: 180 }, - { colKey: 'dept', title: '部门', width: 120 }, - { colKey: 'city', title: '城市', width: 120 }, - { colKey: 'address', title: '地址', width: 220, fixed: 'right' }, - { colKey: 'remark', title: '备注', width: 160 }, - { colKey: 'operation', title: '操作', width: 100 }, -]; - +/** 与 demo 统一的 8 列定义顺序 */ const baseColumns: BaseTableCol[] = [ { colKey: 'id', title: 'ID', width: 100 }, - { colKey: 'name', title: '姓名', width: 120 }, - { colKey: 'email', title: '邮箱', width: 180 }, - { colKey: 'dept', title: '部门', width: 120 }, - { colKey: 'city', title: '城市', width: 120 }, - { colKey: 'address', title: '地址', width: 220 }, - { colKey: 'remark', title: '备注', width: 160 }, - { colKey: 'operation', title: '操作', 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[] = [ @@ -45,6 +34,15 @@ 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 mockTableScroll(content: HTMLElement, scrollLeft: number) { Object.defineProperty(content, 'scrollLeft', { configurable: true, @@ -71,8 +69,8 @@ function getScrollContent(container: HTMLElement) { return container.querySelector('.t-table__content') as HTMLElement; } -function getAddressTh(container: HTMLElement) { - return container.querySelector('th[data-colkey="address"]'); +function getTh(container: HTMLElement, colKey: string) { + return container.querySelector(`th[data-colkey="${colKey}"]`); } async function flushFixedLayout() { @@ -81,64 +79,163 @@ async function flushFixedLayout() { }); } -function DemoLikeTable({ fixedAddress }: { fixedAddress: boolean }) { - const columns = useMemo(() => (fixedAddress ? applyRightAddressFixed(baseColumns) : baseColumns), [fixedAddress]); +function DemoLikeTable({ fixedTarget }: { fixedTarget: 'none' | 'rightAddress' | 'rightDisconnected' }) { + const columns = useMemo(() => { + if (fixedTarget === 'rightAddress') return applyRightAddressFixed(baseColumns); + if (fixedTarget === 'rightDisconnected') return applyRightDisconnectedFixed(baseColumns); + return baseColumns; + }, [fixedTarget]); return ; } describe('右固定 address 集成:border 与 shadow 同步', () => { - it('scrollLeft=0:无 fixed-right-first、无 scrollable-to-right', async () => { - const { container } = render( - , - ); + 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(); - expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); - expect(getAddressTh(container)).not.toHaveClass('t-table__cell--fixed-right-first'); + await waitFor(() => { + expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); + expect(getTh(container, 'address')).toHaveClass('t-table__cell--fixed-right-first'); + }); }); - it('scrollLeft=140:address 有 fixed-right-first 且容器有 scrollable-to-right', async () => { - const { container } = render( - , - ); + 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, 140); + 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(getAddressTh(container)).toHaveClass('t-table__cell--fixed-right-first'); + expect(getTh(container, 'address')).toHaveClass('t-table__cell--fixed-right-first'); }); }); - it('从 scroll=140 回到 0:border 与 shadow 均应清除', async () => { - const { container } = render( - , - ); + it('滚到最右端:border 与 shadow 均应清除', async () => { + const { container } = render(); const content = getScrollContent(container); const tableRoot = getTableRoot(container); - mockTableScroll(content, 140); + 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 }); }); @@ -146,17 +243,58 @@ describe('右固定 address 集成:border 与 shadow 同步', () => { await waitFor(() => { expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); - expect(getAddressTh(container)).not.toHaveClass('t-table__cell--fixed-right-first'); + 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 不应提前加粗', async () => { - const { container, rerender } = render(); + it('从「不固定」切到「右固定 address」后 scroll=0 应显示 border 加粗', async () => { + const { container, rerender } = render(); await flushFixedLayout(); - rerender(); + rerender(); await flushFixedLayout(); const tableRoot = getTableRoot(container); @@ -164,20 +302,27 @@ describe('右固定 address 模式切换(复现 demo Radio 切换)', () => { mockTableScroll(content, 0); await flushFixedLayout(); - expect(tableRoot).not.toHaveClass('t-table__content--scrollable-to-right'); - expect(getAddressTh(container)).not.toHaveClass('t-table__cell--fixed-right-first'); + 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('模式切换后滚到 140 应出现 border 加粗', async () => { - const { container, rerender } = render(); + it('模式切换后滚到 20 应清除 border 加粗', async () => { + const { container, rerender } = render(); await flushFixedLayout(); - rerender(); + rerender(); await flushFixedLayout(); const tableRoot = getTableRoot(container); const content = getScrollContent(container); - mockTableScroll(content, 140); + mockTableScroll(content, 20); await act(() => { fireEvent.scroll(content, { target: content }); @@ -185,8 +330,8 @@ describe('右固定 address 模式切换(复现 demo Radio 切换)', () => { await flushFixedLayout(); await waitFor(() => { - expect(tableRoot).toHaveClass('t-table__content--scrollable-to-right'); - expect(getAddressTh(container)).toHaveClass('t-table__cell--fixed-right-first'); + 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 index a8c3762216..ff182e48b8 100644 --- a/packages/components/table/__tests__/fixed-column-reorder.test.ts +++ b/packages/components/table/__tests__/fixed-column-reorder.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getDeferredRightFixedStickyColKeys, getRightFixedBorderBoundaryColKey, getRightFixedReorderTriggerEntries, getTriggeredRightFixedColKeys, @@ -11,7 +12,39 @@ import { import type { BaseTableCol } from '../type'; -/** 与 fixed-column-reorder demo 右不相连场景一致(含 operation 右固定) */ +/** 与 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' }, @@ -21,33 +54,11 @@ const rightDisconnectedDemoColumns: BaseTableCol[] = [ { colKey: 'operation', width: 100, fixed: 'right' }, ]; -/** 与 demo 右不相连列序一致:id,name,dept,address,city,remark,email,operation */ -const siteRightDisconnectedColumns: BaseTableCol[] = [ - { colKey: 'id', width: 100 }, - { colKey: 'name', width: 120 }, - { colKey: 'dept', width: 120 }, - { colKey: 'address', width: 220, fixed: 'right' }, - { colKey: 'city', width: 120 }, - { colKey: 'remark', width: 160, fixed: 'right' }, - { colKey: 'email', width: 180 }, - { colKey: 'operation', width: 100 }, -]; - -/** 与 demo「右:固定 address」一致 */ -const rightAddressColumns: BaseTableCol[] = [ - { colKey: 'id', width: 100 }, - { colKey: 'name', width: 120 }, - { colKey: 'email', width: 180 }, - { colKey: 'dept', width: 120 }, - { colKey: 'city', width: 120 }, - { colKey: 'address', width: 220, fixed: 'right' }, - { colKey: 'remark', width: 160 }, - { colKey: 'operation', width: 100 }, -]; - 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, @@ -78,84 +89,126 @@ describe('fixed-column-reorder demo:右不相连 partial 重排', () => { }); }); -describe('fixed-column-reorder demo:右重排阈值', () => { +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(rightDisconnectedDemoColumns)).toEqual([ - { colKey: 'address', threshold: 0, widthAfter: 560 }, - { colKey: 'remark', threshold: 0, widthAfter: 280 }, + 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 在 remark', () => { +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, }), - ).toBe('remark'); + ).toBeUndefined(); }); - it('address 与 remark 均重排时 border 在 display 中最靠左的已触发列', () => { + it('address 与 remark 均重排时 border 清除', () => { const display = resolveColumnsForRightFixed(rightDisconnectedDemoColumns, demoScroll(420)); expect(getTriggeredRightFixedColKeys(rightDisconnectedDemoColumns, demoScroll(420))).toEqual(['address', 'remark']); - expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(420))).toBe('address'); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(420))).toBeUndefined(); }); - it('address 重排后 border 仍在 address', () => { + it('滚到最右端无 border', () => { const display = resolveColumnsForRightFixed(rightDisconnectedDemoColumns, demoScroll(560)); - expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(560))).toBe('address'); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedDemoColumns, display, demoScroll(560))).toBeUndefined(); }); }); describe('fixed-column-reorder demo:站点列宽右不相连', () => { - it('scroll=120 remark 达重排阈值:border 在 remark', () => { - const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(120)); - expect(getTriggeredRightFixedColKeys(siteRightDisconnectedColumns, siteScroll(120))).toEqual(['remark']); - expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(120))).toBe('remark'); + 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=280 remark 已重排:border 在 remark(address 阈值超出 maxScrollLeft)', () => { - const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(280)); - expect(getTriggeredRightFixedColKeys(siteRightDisconnectedColumns, siteScroll(280))).toEqual(['remark']); - expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(280))).toBe('remark'); + it('scroll=250 remark 贴右未达阈值:border 在 remark', () => { + expect( + getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(250)), + ).toBe('remark'); }); - it('scroll=400 滚到底:border 仍在 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))).toBe('remark'); + expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(400))).toBeUndefined(); }); }); describe('fixed-column-reorder demo:右固定 address', () => { - it('scroll=140 达重排阈值:border 与阴影同时可激活', () => { - const display = resolveColumnsForRightFixed(rightAddressColumns, rightAddressScroll(140)); - expect(getTriggeredRightFixedColKeys(rightAddressColumns, rightAddressScroll(140))).toEqual(['address']); - expect(getRightFixedBorderBoundaryColKey(rightAddressColumns, display, rightAddressScroll(140))).toBe('address'); - expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(140))).toBe(true); + 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=0 无 border、无阴影', () => { - expect( - getRightFixedBorderBoundaryColKey(rightAddressColumns, rightAddressColumns, rightAddressScroll(0)), - ).toBeUndefined(); - expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(0))).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); }); -}); -describe('fixed-column-reorder demo:阴影与 border 解耦', () => { - it('scroll=50 未重排:无 border,可有阴影', () => { + it('滚到最右端无 border、无阴影', () => { expect( - getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, siteRightDisconnectedColumns, siteScroll(50)), + getRightFixedBorderBoundaryColKey(rightAddressColumns, rightAddressColumns, rightAddressScroll(400)), ).toBeUndefined(); - expect(shouldShowRightFixedColumnShadow(siteRightDisconnectedColumns, siteScroll(50))).toBe(true); + expect(shouldShowRightFixedColumnShadow(rightAddressColumns, rightAddressScroll(400))).toBe(false); }); +}); - it('scroll=280 有 border 且有阴影', () => { - const display = resolveColumnsForRightFixed(siteRightDisconnectedColumns, siteScroll(280)); - expect(getRightFixedBorderBoundaryColKey(siteRightDisconnectedColumns, display, siteScroll(280))).toBe('remark'); - expect(shouldShowRightFixedColumnShadow(siteRightDisconnectedColumns, siteScroll(280))).toBe(true); +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__/reorderFixedColumns.test.ts b/packages/components/table/__tests__/reorderFixedColumns.test.ts index de84bd5b0f..df64ae2cea 100644 --- a/packages/components/table/__tests__/reorderFixedColumns.test.ts +++ b/packages/components/table/__tests__/reorderFixedColumns.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + computeRightFixedOffsets, getLeftFixedBorderBoundaryColKey, getLeftFixedReorderTriggerEntries, getLeftFixedReorderTriggerScrollLeft, getRightFixedBorderBoundaryColKey, getRightFixedReorderTriggerEntries, + getScrollFromRight, getTriggeredLeftFixedColKeys, getTriggeredRightFixedColKeys, hasLeftFixedColumnNeedReorder, @@ -178,9 +180,13 @@ describe('shouldShowLeftFixedColumnShadow', () => { { colKey: 'email', title: '邮箱', width: 200 }, ]; - it('非首列 left fixed 时,scrollLeft > 0 即显示左阴影', () => { - expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 50)).toBe(true); - expect(shouldShowLeftFixedColumnShadow(columnsWithNameFixed, 99)).toBe(true); + 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 时不显示左阴影', () => { @@ -333,39 +339,78 @@ describe('resolveColumnsForRightFixed', () => { }); }); +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('remark 重排后 border 在 remark', () => { + 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(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(200))).toBe('remark'); + expect(getTriggeredRightFixedColKeys(rightDisconnectedColumns, rightScroll(200))).toEqual(['remark']); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(200))).toBeUndefined(); }); - it('remark 达重排阈值即显示 border(镜像左 name@150)', () => { + it('scroll=90 remark 达重排阈值:border 清除', () => { const display = resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(90)); expect(getTriggeredRightFixedColKeys(rightDisconnectedColumns, rightScroll(90))).toEqual(['remark']); - expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(90))).toBe('remark'); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(90))).toBeUndefined(); }); - it('remark 重排后 border 仍在 remark', () => { - const display = resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(150)); - expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, display, rightScroll(150))).toBe('remark'); + it('scroll=79 remark 贴边未达阈值:border 在 remark', () => { + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(79))).toBe( + 'remark', + ); }); - it('未进入重排阶段时返回 undefined,内置重排时不回落默认 border', () => { - expect( - getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50)), - ).toBeUndefined(); + it('scroll=50 未达重排阈值:border 在 remark', () => { + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50))).toBe( + 'remark', + ); }); }); -describe('fixed border 左 sticky / 右重排', () => { - it('未重排 scroll=50:两侧均无 border', () => { +describe('fixed border 左 sticky / 右 scrollFromRight', () => { + it('未重排 scroll=50:左无 border;右 remark 贴边', () => { expect(getLeftFixedBorderBoundaryColKey(disconnectedColumns, disconnectedColumns, 50)).toBeUndefined(); - expect( - getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50)), - ).toBeUndefined(); + expect(getRightFixedBorderBoundaryColKey(rightDisconnectedColumns, rightDisconnectedColumns, rightScroll(50))).toBe( + 'remark', + ); }); - it('首列重排 scroll=150:左 name sticky;右 remark 重排', () => { + it('首列重排 scroll=150:左 name sticky;右 remark 已重排无 border', () => { expect( getLeftFixedBorderBoundaryColKey(disconnectedColumns, resolveColumnsForLeftFixed(disconnectedColumns, 150), 150), ).toBe('name'); @@ -375,7 +420,7 @@ describe('fixed border 左 sticky / 右重排', () => { resolveColumnsForRightFixed(rightDisconnectedColumns, rightScroll(150)), rightScroll(150), ), - ).toBe('remark'); + ).toBeUndefined(); }); }); @@ -388,19 +433,27 @@ describe('shouldShowRightFixedColumnShadow', () => { { colKey: 'operation', width: 100, fixed: 'right' }, ]; - it('未达重排阈值时 scrollLeft > 0 仍可显示右阴影', () => { + it('未达重排阈值时 remark 贴边有 border、有阴影', () => { expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(50))).toBe(true); + expect(getRightFixedBorderBoundaryColKey(columnsWithRemarkFixed, columnsWithRemarkFixed, rightScroll(50))).toBe( + 'remark', + ); }); - it('scrollLeft 为 0 时不显示右阴影', () => { - expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(0))).toBe(false); + it('scrollLeft 为 0 时 remark 贴边:有 border、有 shadow', () => { + expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(0))).toBe(true); + expect(getRightFixedBorderBoundaryColKey(columnsWithRemarkFixed, columnsWithRemarkFixed, rightScroll(0))).toBe( + 'remark', + ); }); - it('达重排阈值时 border 在 remark,阴影由横滚决定', () => { - const display = resolveColumnsForRightFixed(columnsWithRemarkFixed, rightScroll(90)); - expect(getRightFixedBorderBoundaryColKey(columnsWithRemarkFixed, display, rightScroll(90))).toBe('remark'); - expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(90))).toBe(true); - expect(shouldShowRightFixedColumnShadow(columnsWithRemarkFixed, rightScroll(70))).toBe(true); + 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); }); }); diff --git a/packages/components/table/_example/fixed-column-reorder.tsx b/packages/components/table/_example/fixed-column-reorder.tsx index 82722e83f3..06a514a1d1 100644 --- a/packages/components/table/_example/fixed-column-reorder.tsx +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -1,265 +1,340 @@ -import React, { useMemo, useRef, useState } from 'react'; +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_CLIENT_WIDTH = 720; +const TABLE_WIDTH = 720; -/** 固定列场景:左 / 右 对称的内置重排演示 */ -type FixedTarget = +/** + * 全部模式(含 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' - | 'name' - | 'dept' - | 'connected' - | 'disconnected' + | 'leftName' + | 'leftDisconnected' + | 'leftConnected' | 'rightAddress' | 'rightDisconnected' | 'rightConnected'; -type StageHint = { label: string; order: string }; - -function getStageHints(fixedTarget: FixedTarget): StageHint[] { - switch (fixedTarget) { - case 'name': - return [ - { - label: 'name 贴左(scrollLeft ≥ 100px)', - order: 'name → id → email → dept → ...', - }, - ]; - case 'disconnected': - return [ - { - label: 'name 贴左(scrollLeft ≥ 100px)', - order: 'name → id → email → dept → city → ...', - }, - { - label: 'dept 重排前置(scrollLeft ≥ 400px)', - order: 'name → dept → id → email → city → ...', - }, - ]; - case 'rightAddress': - return [ - { - label: 'address 贴右(scrollLeft ≥ 140px)', - order: '... → address → remark → operation', - }, - ]; - case 'rightDisconnected': - return [ - { - label: '定义结构(镜像左不相连)', - order: 'address(fixed) → city → remark(fixed) → email → operation', - }, - { - label: 'remark 贴右后置(scrollLeft ≥ 120px)', - order: '... → remark → operation', - }, - ]; - default: - return []; +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 }; } -function needsReorderHint(fixedTarget: FixedTarget): boolean { - return ['name', 'dept', 'disconnected', 'rightAddress', 'rightDisconnected'].includes(fixedTarget); +/** 与 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 needsRightReorderHint(fixedTarget: FixedTarget): boolean { - return fixedTarget === 'rightAddress' || fixedTarget === 'rightDisconnected'; +function sumColumnWidth(columns: PrimaryTableCol[]): number { + return columns.reduce((sum, col) => sum + Number(col.width ?? 0), 0); } -const data: TableRowData[] = []; -for (let i = 0; i < 10; i++) { - data.push({ - id: i + 1, - name: ['张三', '李四', '王芳'][i % 3], - email: ['a@example.com', 'b@example.com', 'c@example.com'][i % 3], - dept: ['研发', '设计', '产品'][i % 3], - city: ['深圳', '上海', '北京'][i % 3], - address: ['南山区科技园', '浦东新区陆家嘴', '朝阳区望京'][i % 3], - remark: ['备注 A', '备注 B', '备注 C'][i % 3], - }); +function getHeaderSignature(columns: PrimaryTableCol[]): string { + return columns.map((col) => col.title).join(' | '); } -/** 根据场景为列设置 fixed,并在右不相连时重排列序(镜像左:name—email—dept) */ -function applyFixedTarget(cols: PrimaryTableCol[], fixedTarget: FixedTarget): PrimaryTableCol[] { - const leftFixedKeys = new Set(); - const rightFixedKeys = new Set(); +/** 从表头 DOM 读取 colKey 顺序(反映 Table 是否做了列重排) */ +function readDomHeaderColKeys(tableRoot: HTMLElement | null): ColumnKey[] { + if (!tableRoot) return []; + const ths = tableRoot.querySelectorAll('thead tr:first-child th[data-colkey]'); + return Array.from(ths) + .map((th) => th.getAttribute('data-colkey')) + .filter((key): key is ColumnKey => !!key && COLUMN_KEYS.includes(key as ColumnKey)); +} - if (fixedTarget === 'name') leftFixedKeys.add('name'); - if (fixedTarget === 'dept') leftFixedKeys.add('dept'); - if (fixedTarget === 'connected') { - leftFixedKeys.add('id'); - leftFixedKeys.add('name'); - } - if (fixedTarget === 'disconnected') { - leftFixedKeys.add('name'); - leftFixedKeys.add('dept'); - } - if (fixedTarget === 'rightAddress') rightFixedKeys.add('address'); - if (fixedTarget === 'rightDisconnected') { - rightFixedKeys.add('address'); - rightFixedKeys.add('remark'); - } - if (fixedTarget === 'rightConnected') { - rightFixedKeys.add('remark'); - rightFixedKeys.add('operation'); - } +/** 按视口 left 排序,得到肉眼看到的从左到右表头顺序 */ +function readVisualHeaderColKeys(tableRoot: HTMLElement | null): ColumnKey[] { + if (!tableRoot) return []; + const ths = tableRoot.querySelectorAll('thead tr:first-child th[data-colkey]'); + return Array.from(ths) + .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); +} - const colMap = Object.fromEntries(cols.map((col) => [String(col.colKey), col])); +function colKeysToTitles(keys: ColumnKey[]): string { + return keys.map((key) => COLUMN_META[key].title).join(' | '); +} - const attachFixed = (col: PrimaryTableCol): PrimaryTableCol => { - const key = String(col.colKey); - if (leftFixedKeys.has(key)) return { ...col, fixed: 'left' as const }; - if (rightFixedKeys.has(key)) return { ...col, fixed: 'right' as const }; - if (col.fixed === 'left' || col.fixed === 'right') { - const nextCol = { ...col }; - delete nextCol.fixed; - return nextCol; - } - return col; - }; +const NONE_COL_KEYS = [...COLUMN_KEYS]; - // 右不相连:address(R) — city — remark(R) — email — operation(镜像左:仅 name、dept 左固定) - if (fixedTarget === 'rightDisconnected') { - const orderedKeys = ['id', 'name', 'dept', 'address', 'city', 'remark', 'email', 'operation']; - return orderedKeys.map((key) => attachFixed(colMap[key])).filter(Boolean); - } +function isRightFixedMode(mode: DemoMode): boolean { + return (RIGHT_MODES as readonly DemoMode[]).includes(mode); +} - // 右相连:remark + operation 贴右连续 fixed - if (fixedTarget === 'rightConnected') { - const orderedKeys = ['id', 'name', 'email', 'dept', 'city', 'address', 'remark', 'operation']; - return orderedKeys.map((key) => attachFixed(colMap[key])).filter(Boolean); +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 cols.map((col) => attachFixed(col)); + return matchesNone ? ' ✓ 与 None 一致' : mismatchText; } export default function TableFixedColumnReorder() { const tableRef = useRef(null); - const [fixedTarget, setFixedTarget] = useState('none'); - - const baseColumns: PrimaryTableCol[] = useMemo( - () => [ - { colKey: 'id', title: 'ID(定义第 1 列)', width: 100 }, - { colKey: 'name', title: '姓名(定义第 2 列)', width: 120 }, - { colKey: 'email', title: '邮箱(定义第 3 列)', width: 180 }, - { colKey: 'dept', title: '部门(定义第 4 列)', width: 120 }, - { colKey: 'city', title: '城市', width: 120 }, - { colKey: 'address', title: '地址', width: 220 }, - { colKey: 'remark', title: '备注', width: 160 }, - { - colKey: 'operation', - title: '操作', - width: 100, - cell: () => ( - - ), - }, - ], - [], - ); + const [mode, setMode] = useState('none'); + const [scrollLeft, setScrollLeft] = useState(0); + const [domColKeys, setDomColKeys] = useState([]); + const [visualColKeys, setVisualColKeys] = useState([]); - const columns = useMemo( - (): PrimaryTableCol[] => applyFixedTarget(baseColumns, fixedTarget), - [baseColumns, fixedTarget], - ); + 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 colWidths = useMemo( - () => - Object.fromEntries( - baseColumns.filter((col) => col.colKey).map((col) => [String(col.colKey), col.width as number]), - ), - [baseColumns], + 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(() => { + tableRef.current?.tableContentElement?.scrollTo({ left: 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 不一致', ); - const maxScrollLeft = useMemo(() => { - const total = columns.reduce((sum, col) => sum + Number(col.width ?? colWidths[String(col.colKey)] ?? 0), 0); - return Math.max(0, total - TABLE_CLIENT_WIDTH); - }, [columns, colWidths]); + let visualTagTheme: 'success' | 'danger' | 'primary' = 'primary'; + if (isAtScrollStart) { + if (expectsRightFixedVisualDiff) { + visualTagTheme = visualMatchesNone ? 'danger' : 'success'; + } else { + visualTagTheme = visualMatchesNone ? 'success' : 'danger'; + } + } - const renderOrderHint = useMemo(() => { - const defineOrder = columns.map((col) => col.colKey).join(' → '); - const stageHints = getStageHints(fixedTarget); - return { defineOrder, stageHints }; - }, [columns, fixedTarget]); + const visualTagSuffix = getScrollCompareSuffix( + visualColKeys.length > 0, + isAtScrollStart, + visualMatchesNone, + ' ✓ 右 fixed 滚动后视觉变化正常', + ' ✗ 与 None 不一致', + expectsRightFixedVisualDiff, + ); return ( - setFixedTarget(val)}> - 不固定 - 左:固定 name - 左:不相连(name+dept) - 左:相连(id+name) - 右:固定 address - 右:不相连(address+remark) - 右:相连(remark+operation) + setMode(val)}> + + {DEMO_MODES.none.label} + + + + 左侧 + + {LEFT_MODES.map((key) => ( + + {DEMO_MODES[key].label} + + ))} + + + + + 右侧 + + {RIGHT_MODES.map((key) => ( + + {DEMO_MODES[key].label} + + ))} + + - - 定义顺序:{renderOrderHint.defineOrder} + + columns 定义(colKey): {configColKeys.join(' → ')} - - scrollLeft=0 渲染顺序与定义一致 + + columns title: {configHeader} - {fixedTarget === 'connected' && ( - - 从左连续 left fixed,不触发重排 - - )} - {fixedTarget === 'rightConnected' && ( - - 从右连续 right fixed,不触发重排 - - )} - {fixedTarget === 'rightDisconnected' && ( - - 仅 address、remark 右固定(镜像左 name+dept);city、email 可滚动,operation 贴末列不固定 - - )} - {needsReorderHint(fixedTarget) && ( - - 预估 maxScrollLeft ≈ {maxScrollLeft}px(表格宽 {TABLE_CLIENT_WIDTH} - px) + + DOM 表头顺序(scrollLeft={scrollLeft}px): {domColKeys.length ? colKeysToTitles(domColKeys) : '读取中…'} + {domTagSuffix} + + + 视觉从左到右(scrollLeft={scrollLeft}px): + {visualColKeys.length ? colKeysToTitles(visualColKeys) : '读取中…'} + {visualTagSuffix} + + {modeConfig.note && ( + + {modeConfig.note} )} - {needsRightReorderHint(fixedTarget) && ( + {mode !== 'none' && mode !== 'leftConnected' && mode !== 'rightConnected' && ( - 右侧 border 在重排阈值后出现;加粗还需横滚(scrollLeft > 0 且未滚到底) + maxScrollLeft ≈ {maxScrollLeft}px(table {TABLE_WIDTH}px) )} - {renderOrderHint.stageHints.map((stage) => ( - - {stage.label}:{stage.order} - - ))}
); diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index 7ef5b1fbf1..392ae645bf 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -11,8 +11,11 @@ import { resizeObserverElement } from '../utils'; import { buildFixedLayoutState, markFixedColumnBoundaries } from '../utils/markFixedColumnBoundaries'; import { createFixedLayoutScrollMetrics, + getColumnsTotalWidth, + getDeferredRightFixedStickyColKeys, hasFixedColumnNeedReorder, resolveDisplayColumnsForFixed, + resolveRightBorderBoundaryColKey, shouldShowLeftFixedColumnShadow, shouldShowRightFixedColumnShadow, } from '../utils/reorderFixedColumns'; @@ -31,7 +34,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', @@ -153,7 +156,16 @@ export default function useFixed( const getFixedLayoutScrollMetrics = () => { const el = tableContentRef.current; if (!el) return createFixedLayoutScrollMetrics(0, 0, 0); - return createFixedLayoutScrollMetrics(el.scrollLeft, el.scrollWidth, el.clientWidth); + const metrics = createFixedLayoutScrollMetrics(el.scrollLeft, el.scrollWidth, el.clientWidth); + if (metrics.maxScrollLeft > 0 || !hasFixedColumnNeedReorder(originColumns)) { + return metrics; + } + // 列 fixed 切换后 DOM scrollWidth 可能尚未更新,用列宽总和兜底 + const colWidths = thWidthList.current; + const totalWidth = getColumnsTotalWidth(originColumns, colWidths); + const fallbackMax = Math.max(0, totalWidth - el.clientWidth); + if (fallbackMax <= 0) return metrics; + return { scrollLeft: el.scrollLeft, maxScrollLeft: fallbackMax }; }; const calculateThWidthList = (trList: HTMLCollection) => { @@ -263,7 +275,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; @@ -280,6 +295,20 @@ 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, @@ -305,6 +334,7 @@ export default function useFixed( } } setFixedLeftPos(displayColumns, initialColumnMap); + applyDeferredRightStickyFlags(initialColumnMap, originColumns, getFixedLayoutScrollMetrics()); setFixedRightPos(displayColumns, initialColumnMap); }; @@ -393,6 +423,7 @@ export default function useFixed( ...existing, lastLeftFixedCol: node.lastLeftFixedCol, firstRightFixedCol: node.firstRightFixedCol, + deferRightSticky: existing.deferRightSticky, }); } else { next.set(key, { ...node }); @@ -409,15 +440,21 @@ export default function useFixed( }); 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 scrollMetrics = createFixedLayoutScrollMetrics(scrollLeft, target.scrollWidth, target.clientWidth); - const isShowRight = shouldShowRightFixedColumnShadow(originColumns, scrollMetrics); - const isShowLeft = shouldShowLeftFixedColumnShadow(originColumns, scrollLeft); + 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 && hasFixedSideColumn(originColumns, 'left'), @@ -426,13 +463,33 @@ export default function useFixed( }; // 多级表头场景较为复杂:为了滚动的阴影效果,需要知道哪些列是边界列 - const setIsLastOrFirstFixedCol = ( - levelNodes: FixedColumnInfo[][], + const buildLayoutForBoundaryMark = ( displayColumns: BaseTableCol[] = finalColumns, scrollMetrics = getFixedLayoutScrollMetrics(), ) => { - const layout = buildFixedLayoutState(originColumns, displayColumns, scrollMetrics, getThWidthList()); - markFixedColumnBoundaries(levelNodes, layout); + 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}`, + }; }; const hasFixedColumns = (cols: BaseTableCol[] = []): boolean => @@ -445,21 +502,20 @@ export default function useFixed( setIsFixedColumn(false); const scrollMetrics = getFixedLayoutScrollMetrics(); const displayColumns = resolveDisplayColumns(displayColumnsOverride); - lastFixedBorderSignatureRef.current = buildFixedLayoutState( - originColumns, - displayColumns, - scrollMetrics, - getThWidthList(), - ).layoutSignature; const { newColumnsMap, levelNodes } = getColumnMap(displayColumns); - setIsLastOrFirstFixedCol(levelNodes, displayColumns, scrollMetrics); + 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 }); + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }, displayColumns); }; // 使用 useCallback 来优化性能 @@ -562,9 +618,7 @@ export default function useFixed( // eslint-disable-next-line react-hooks/exhaustive-deps useDeepEffect(() => { if (isFixedColumn) { - updateColumnFixedShadow(tableContentRef.current, { - skipScrollLimit: true, - }); + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }, resolveDisplayColumns()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFixedColumn, finalColumns, tableContentRef]); @@ -660,20 +714,20 @@ export default function useFixed( updateFixedHeader(); }; - /** 横向滚动时同步 fixed 边界(左 last / 右 first),仅更新 border 标记与阴影,避免列序 DOM 未更新时重算 sticky */ + /** 横向滚动时同步 fixed 边界(左 last / 右 first),优先用已算好的 sticky right 偏移 */ const syncFixedColumnBorder = (displayColumnsOverride?: BaseTableCol[]) => { if (!hasFixedColumnNeedReorder(originColumns)) return; const scrollMetrics = getFixedLayoutScrollMetrics(); const displayColumns = resolveDisplayColumns(displayColumnsOverride); - const layout = buildFixedLayoutState(originColumns, displayColumns, scrollMetrics, getThWidthList()); - if (lastFixedBorderSignatureRef.current === layout.layoutSignature) return; - lastFixedBorderSignatureRef.current = layout.layoutSignature; - const { newColumnsMap, levelNodes } = getColumnMap(displayColumns); - setIsLastOrFirstFixedCol(levelNodes, displayColumns, scrollMetrics); + 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 }); + updateColumnFixedShadow(tableContentRef.current, { skipScrollLimit: true }, displayColumns); }; return { 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 index 08f1f72dc3..59e03cf262 100644 --- a/packages/components/table/utils/markFixedColumnBoundaries.ts +++ b/packages/components/table/utils/markFixedColumnBoundaries.ts @@ -17,7 +17,7 @@ function resetFixedBoundaryFlags(levelNodes: FixedColumnInfo[][]): void { /** * 标记 fixed-left-last / fixed-right-first 边界列。 - * 左 border:sticky 几何;右 border:重排阈值。阴影 gate 仅反映横滚(见 shouldShow*FixedColumnShadow)。 + * 右 border:单列未达重排阈值贴边;多列按 widthAfter 交接。右 shadow 与 border 同步。 */ export function markFixedColumnBoundaries( levelNodes: FixedColumnInfo[][], @@ -35,7 +35,7 @@ export function markFixedColumnBoundaries( const isParentLastLeftFixedCol = !parent || parent?.lastLeftFixedCol; const isParentFirstRightFixedCol = !parent || parent?.firstRightFixedCol; - // 内置重排:左 border 为 sticky 边界,右 border 为重排阈值;启用时禁止回落默认规则 + // 内置重排:左右 border 均为 sticky 边界;启用时禁止回落默认规则 if (layout.left.enabled) { if (layout.left.borderBoundaryColKey && colMapInfo.col.colKey === layout.left.borderBoundaryColKey) { colMapInfo.lastLeftFixedCol = true; diff --git a/packages/components/table/utils/reorderFixedColumns.ts b/packages/components/table/utils/reorderFixedColumns.ts index f58305a237..9fe1c9a87f 100644 --- a/packages/components/table/utils/reorderFixedColumns.ts +++ b/packages/components/table/utils/reorderFixedColumns.ts @@ -14,7 +14,7 @@ export interface FixedLayoutScrollMetrics { /** 单侧 fixed 重排触发项 */ export interface FixedReorderTriggerEntry { colKey: string; - /** 左:scrollLeft >= threshold;右:scrollLeft >= maxScrollLeft - widthAfter */ + /** 左:scrollLeft >= widthBefore;右:scrollFromRight <= widthAfter */ threshold: number; widthAfter?: number; } @@ -71,19 +71,10 @@ function isLeadingColumn(col: BaseTableCol): boolean return Boolean(col.colKey && TABLE_LEADING_COLUMN_KEYS.has(col.colKey)); } -/** 在 displayColumns 中取最靠左、且已触发重排的 right fixed 列 */ -function findLeftmostTriggeredRightFixedColKey( - displayColumns: BaseTableCol[] = [], - triggeredColKeys: Set = new Set(), -): string | undefined { - for (let i = 0, len = displayColumns.length; i < len; i++) { - const col = displayColumns[i]; - const colKey = String(col.colKey ?? i); - if (col.fixed === 'right' && triggeredColKeys.has(colKey)) { - return colKey; - } - } - return undefined; +/** 距右缘剩余可滚距离(DOM 仅有 scrollLeft,右向判断统一用此量) */ +export function getScrollFromRight(scrollMetrics: FixedLayoutScrollMetrics): number { + const { scrollLeft, maxScrollLeft } = scrollMetrics; + return Math.max(0, maxScrollLeft - scrollLeft); } export function getColumnsTotalWidth( @@ -215,24 +206,211 @@ export function getLeftFixedBorderBoundaryColKey( } /** - * 右侧 fixed border 边界(fixed-right-first)。 - * 内置重排:跟重排阈值,取 display 中最靠左的已触发 right fixed 列。 + * 与 setFixedRightPos 相同规则,基于 display 列序与列宽计算 right fixed 的 sticky right 偏移。 + * border 边界取已触发列中 right 偏移最大者(贴边栈最左缘,与 fixed-right-first 一致)。 */ -export function getRightFixedBorderBoundaryColKey( - originColumns: BaseTableCol[] = [], +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 { scrollLeft } = scrollMetrics; - if (scrollLeft <= 0 || !hasRightFixedColumnNeedReorder(originColumns)) { + 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; } - const triggeredKeys = new Set(getTriggeredRightFixedColKeys(originColumns, scrollMetrics, colWidths)); - if (!triggeredKeys.size) return undefined; + return getRightFixedMultiBorderBoundaryColKey(originColumns, scrollMetrics, colWidths); +} - return findLeftmostTriggeredRightFixedColKey(displayColumns, triggeredKeys); +/** 结合布局快照,解析右侧 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( @@ -246,11 +424,12 @@ export function isLeftFixedReorderTriggered( export function shouldShowLeftFixedColumnShadow( columns: BaseTableCol[] = [], scrollLeft = 0, + colWidths: Record = {}, ): boolean { if (scrollLeft <= 0) return false; if (hasLeftFixedColumnNeedReorder(columns)) { - // 内置重排:阴影仅表示「已发生横滚」,与 border 列(sticky)解耦;加粗需二者同时满足 - return true; + // 与左 border(sticky)相反:左阴影跟重排阈值 + return isLeftFixedReorderTriggered(columns, scrollLeft, colWidths); } return true; } @@ -319,7 +498,7 @@ function buildLeftSideLayoutState( enabled: false, reorderTriggeredKeys: [], borderBoundaryColKey: undefined, - showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft), + showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), reorderSignature: '', sideLayoutSignature: '', }; @@ -333,7 +512,7 @@ function buildLeftSideLayoutState( enabled: true, reorderTriggeredKeys, borderBoundaryColKey, - showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft), + showShadow: shouldShowLeftFixedColumnShadow(originColumns, scrollLeft, colWidths), reorderSignature, sideLayoutSignature: `${borderBoundaryColKey ?? ''}|${reorderSignature}`, }; @@ -409,13 +588,15 @@ export function getTriggeredRightFixedColKeys( colWidths: Record = {}, ): string[] { const { scrollLeft, maxScrollLeft } = scrollMetrics; - if (maxScrollLeft <= 0 || !hasRightFixedColumnNeedReorder(columns)) return []; + if (maxScrollLeft <= 0 || scrollLeft <= 0 || !hasRightFixedColumnNeedReorder(columns)) return []; + + const scrollFromRight = getScrollFromRight(scrollMetrics); return getRightFixedReorderTriggerEntries(columns, colWidths) .filter((entry) => { const widthAfter = entry.widthAfter ?? 0; - const threshold = maxScrollLeft - widthAfter; - return threshold >= 0 && scrollLeft >= threshold; + if (widthAfter > maxScrollLeft) return false; + return scrollFromRight <= widthAfter; }) .map((entry) => entry.colKey); } @@ -431,16 +612,44 @@ export function isRightFixedReorderTriggered( export function shouldShowRightFixedColumnShadow( columns: BaseTableCol[] = [], scrollMetrics: FixedLayoutScrollMetrics = { scrollLeft: 0, maxScrollLeft: 0 }, + colWidths: Record = {}, + displayColumns: BaseTableCol[] = [], ): boolean { - const { scrollLeft, maxScrollLeft } = scrollMetrics; - if (maxScrollLeft <= 0 || scrollLeft <= 0) return false; if (hasRightFixedColumnNeedReorder(columns)) { - // 内置重排:阴影仅表示「仍可向右滚动」,与 border 列(重排阈值)解耦;加粗需二者同时满足 - return scrollLeft < maxScrollLeft; + 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--) { @@ -543,7 +752,7 @@ function buildRightSideLayoutState( enabled: false, reorderTriggeredKeys: [], borderBoundaryColKey: undefined, - showShadow: shouldShowRightFixedColumnShadow(originColumns, scrollMetrics), + showShadow: shouldShowRightFixedColumnShadow(originColumns, scrollMetrics, colWidths, displayColumns), reorderSignature: '', sideLayoutSignature: '', }; @@ -562,7 +771,7 @@ function buildRightSideLayoutState( enabled: true, reorderTriggeredKeys, borderBoundaryColKey, - showShadow: shouldShowRightFixedColumnShadow(originColumns, scrollMetrics), + showShadow: shouldShowRightFixedColumnShadow(originColumns, scrollMetrics, colWidths, displayColumns), reorderSignature, sideLayoutSignature: `${borderBoundaryColKey ?? ''}|${reorderSignature}`, }; 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'); From 061510f5a165bbb6848a1b767ed962da431730a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Fri, 26 Jun 2026 03:06:14 +0800 Subject: [PATCH 07/10] fix: typos --- .../components/table/_example/fixed-column-reorder.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/table/_example/fixed-column-reorder.tsx b/packages/components/table/_example/fixed-column-reorder.tsx index 06a514a1d1..b4238a2840 100644 --- a/packages/components/table/_example/fixed-column-reorder.tsx +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -135,8 +135,8 @@ function getHeaderSignature(columns: PrimaryTableCol[]): string { /** 从表头 DOM 读取 colKey 顺序(反映 Table 是否做了列重排) */ function readDomHeaderColKeys(tableRoot: HTMLElement | null): ColumnKey[] { if (!tableRoot) return []; - const ths = tableRoot.querySelectorAll('thead tr:first-child th[data-colkey]'); - return Array.from(ths) + 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)); } @@ -144,8 +144,8 @@ function readDomHeaderColKeys(tableRoot: HTMLElement | null): ColumnKey[] { /** 按视口 left 排序,得到肉眼看到的从左到右表头顺序 */ function readVisualHeaderColKeys(tableRoot: HTMLElement | null): ColumnKey[] { if (!tableRoot) return []; - const ths = tableRoot.querySelectorAll('thead tr:first-child th[data-colkey]'); - return Array.from(ths) + 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, From 2fa944e02eb895b58b7200f9837440396747be12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Fri, 26 Jun 2026 03:34:47 +0800 Subject: [PATCH 08/10] feat(table): enhance fixed column boundary marking --- .../markFixedColumnBoundaries.test.ts | 35 +- .../table/_example/fixed-column-reorder.tsx | 9 +- .../table/utils/markFixedColumnBoundaries.ts | 15 +- .../table/utils/reorderFixedColumns.ts | 1 + test/snap/__snapshots__/csr.test.jsx.snap | 840 ++++++++++++++++++ test/snap/__snapshots__/ssr.test.jsx.snap | 2 + 6 files changed, 889 insertions(+), 13 deletions(-) diff --git a/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts b/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts index 9b8f35c2c9..dbd4c41dac 100644 --- a/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts +++ b/packages/components/table/__tests__/markFixedColumnBoundaries.test.ts @@ -82,7 +82,7 @@ describe('markFixedColumnBoundaries', () => { expect(levelNodes[0][4].firstRightFixedCol).toBe(false); }); - it('左侧内置重排启用时,右侧不回落默认 border 规则', () => { + it('左侧内置重排启用时,右侧连续固定列仍按默认规则标记 border', () => { const levelNodes = createLevelNodes(['id', 'name', 'email', 'remark', 'operation'], { remark: 'right', operation: 'right', @@ -111,7 +111,38 @@ describe('markFixedColumnBoundaries', () => { markFixedColumnBoundaries(levelNodes, layout); - expect(levelNodes[0][3].firstRightFixedCol).toBe(false); + 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/_example/fixed-column-reorder.tsx b/packages/components/table/_example/fixed-column-reorder.tsx index b4238a2840..788c8a1907 100644 --- a/packages/components/table/_example/fixed-column-reorder.tsx +++ b/packages/components/table/_example/fixed-column-reorder.tsx @@ -210,7 +210,14 @@ export default function TableFixedColumnReorder() { // 切换模式时回到 scrollLeft=0,避免沿用上一模式的滚动 / 重排列序 useEffect(() => { - tableRef.current?.tableContentElement?.scrollTo({ left: 0 }); + const content = tableRef.current?.tableContentElement; + if (content) { + if (typeof content.scrollTo === 'function') { + content.scrollTo({ left: 0 }); + } else { + content.scrollLeft = 0; + } + } setScrollLeft(0); }, [mode]); diff --git a/packages/components/table/utils/markFixedColumnBoundaries.ts b/packages/components/table/utils/markFixedColumnBoundaries.ts index 59e03cf262..018d51fa79 100644 --- a/packages/components/table/utils/markFixedColumnBoundaries.ts +++ b/packages/components/table/utils/markFixedColumnBoundaries.ts @@ -17,7 +17,10 @@ function resetFixedBoundaryFlags(levelNodes: FixedColumnInfo[][]): void { /** * 标记 fixed-left-last / fixed-right-first 边界列。 - * 右 border:单列未达重排阈值贴边;多列按 widthAfter 交接。右 shadow 与 border 同步。 + * + * 左右两侧独立判断,互不影响: + * - 单侧启用内置重排:仅按 borderBoundaryColKey 标记,不回落默认规则 + * - 单侧未启用重排:与 develop 一致,按列序标记首个边界(与 showShadow / 是否溢出无关) */ export function markFixedColumnBoundaries( levelNodes: FixedColumnInfo[][], @@ -35,17 +38,11 @@ export function markFixedColumnBoundaries( const isParentLastLeftFixedCol = !parent || parent?.lastLeftFixedCol; const isParentFirstRightFixedCol = !parent || parent?.firstRightFixedCol; - // 内置重排:左右 border 均为 sticky 边界;启用时禁止回落默认规则 if (layout.left.enabled) { if (layout.left.borderBoundaryColKey && colMapInfo.col.colKey === layout.left.borderBoundaryColKey) { colMapInfo.lastLeftFixedCol = true; } - } else if ( - !layout.enabled && - isParentLastLeftFixedCol && - colMapInfo.col.fixed === 'left' && - nextColMapInfo?.col.fixed !== 'left' - ) { + } else if (isParentLastLeftFixedCol && colMapInfo.col.fixed === 'left' && nextColMapInfo?.col.fixed !== 'left') { colMapInfo.lastLeftFixedCol = true; } @@ -54,8 +51,6 @@ export function markFixedColumnBoundaries( colMapInfo.firstRightFixedCol = true; } } else if ( - !layout.enabled && - layout.right.showShadow && isParentFirstRightFixedCol && colMapInfo.col.fixed === 'right' && lastColMapInfo?.col.fixed !== 'right' diff --git a/packages/components/table/utils/reorderFixedColumns.ts b/packages/components/table/utils/reorderFixedColumns.ts index 9fe1c9a87f..fc6a86b5fd 100644 --- a/packages/components/table/utils/reorderFixedColumns.ts +++ b/packages/components/table/utils/reorderFixedColumns.ts @@ -615,6 +615,7 @@ export function shouldShowRightFixedColumnShadow( colWidths: Record = {}, displayColumns: BaseTableCol[] = [], ): boolean { + // 容器阴影:重排模式跟随 border 边界;标准右固定与 develop 一致,看是否还能右滚 if (hasRightFixedColumnNeedReorder(columns)) { const display = displayColumns.length ? displayColumns : columns; return !!getRightFixedBorderBoundaryColKey(columns, display, scrollMetrics, colWidths); diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index 2f2ebb8e03..13b8cb0e73 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 c5f6ae5b2e..18868719f6 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条----
"`; From 2fea0220fc886ba0dee586b28584d5d68a180e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Sat, 27 Jun 2026 20:56:32 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat(table):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=88=97=E7=9A=84=E6=BB=9A=E5=8A=A8=E5=BA=A6?= =?UTF-8?q?=E9=87=8F=E8=AF=BB=E5=8F=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/table/BaseTable.tsx | 20 ++- .../fixed-column-reorder.integration.test.tsx | 150 +++++++++++++++++- .../__tests__/reorderFixedColumns.test.ts | 43 +++++ packages/components/table/hooks/useFixed.ts | 19 +-- .../table/utils/reorderFixedColumns.ts | 19 +++ 5 files changed, 226 insertions(+), 25 deletions(-) diff --git a/packages/components/table/BaseTable.tsx b/packages/components/table/BaseTable.tsx index c393783a39..47f639890e 100644 --- a/packages/components/table/BaseTable.tsx +++ b/packages/components/table/BaseTable.tsx @@ -33,9 +33,9 @@ import THead from './THead'; import { ROW_LISTENERS } from './TR'; import { getAffixProps } from './utils'; import { - createFixedLayoutScrollMetrics, hasFixedColumnNeedReorder, isSameDisplayColumns, + readFixedLayoutScrollMetricsFromElement, resolveFixedColumnLayout, } from './utils/reorderFixedColumns'; import { scheduleAfterColumnDomUpdate } from './utils/scheduleAfterColumnDomUpdate'; @@ -47,6 +47,7 @@ import type { BaseTableProps, BaseTableRef } from './interface'; import type { TableBodyProps } from './TBody'; import type { TheadProps } from './THead'; 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); @@ -83,6 +84,7 @@ const BaseTable = forwardRef((originalProps, ref) const fixedLayoutSignatureRef = useRef(''); const fixedReorderSignatureRef = useRef(''); const scrollFixedLayoutRafRef = useRef(); + const pendingScrollLayoutRef = useRef | null>(null); const lastScrollLeftRef = useRef(0); const fixedLayoutActionsRef = useRef<{ getThWidthList: () => Record; @@ -188,16 +190,17 @@ const BaseTable = forwardRef((originalProps, ref) }, []); const readFixedLayoutScrollMetrics = useCallback(() => { - const el = fixedLayoutActionsRef.current.tableContentRef.current; - if (!el) return createFixedLayoutScrollMetrics(0, 0, 0); - return createFixedLayoutScrollMetrics(el.scrollLeft, el.scrollWidth, el.clientWidth); - }, []); + 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 scrollMetrics = readFixedLayoutScrollMetrics(); - const layout = resolveFixedColumnLayout(originColumns, scrollMetrics, getThWidthList()); + const pendingLayout = pendingScrollLayoutRef.current; + pendingScrollLayoutRef.current = null; + const layout = + pendingLayout ?? resolveFixedColumnLayout(originColumns, readFixedLayoutScrollMetrics(), getThWidthList()); if (!layout.enabled) { resetFixedLayoutSignatures(); @@ -401,7 +404,7 @@ const BaseTable = forwardRef((originalProps, ref) if (left !== lastScrollLeftRef.current) { lastScrollLeftRef.current = left; if (hasFixedColumnNeedReorder(originColumns)) { - const scrollMetrics = createFixedLayoutScrollMetrics(left, target.scrollWidth, target.clientWidth); + 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; @@ -414,6 +417,7 @@ const BaseTable = forwardRef((originalProps, ref) fixedLayoutActionsRef.current.syncFixedColumnBorder(layout.displayColumns); } + pendingScrollLayoutRef.current = layout; scheduleScrollFixedColumnLayout(); } else { updateColumnFixedShadow(target); diff --git a/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx b/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx index c31be7bf11..aed305ce6d 100644 --- a/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx +++ b/packages/components/table/__tests__/fixed-column-reorder.integration.test.tsx @@ -43,6 +43,19 @@ function applyRightDisconnectedFixed(cols: BaseTableCol[]): BaseTableCol[] { }); } +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, @@ -73,14 +86,24 @@ 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' | 'rightAddress' | 'rightDisconnected' }) { +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; @@ -88,6 +111,131 @@ function DemoLikeTable({ fixedTarget }: { fixedTarget: 'none' | 'rightAddress' | 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(); diff --git a/packages/components/table/__tests__/reorderFixedColumns.test.ts b/packages/components/table/__tests__/reorderFixedColumns.test.ts index df64ae2cea..84c642dcbb 100644 --- a/packages/components/table/__tests__/reorderFixedColumns.test.ts +++ b/packages/components/table/__tests__/reorderFixedColumns.test.ts @@ -14,6 +14,7 @@ import { hasRightFixedColumnNeedReorder, isLeftFixedReorderTriggered, isSameDisplayColumns, + readFixedLayoutScrollMetricsFromElement, reorderColumnsForLeftFixed, reorderColumnsForLeftFixedPartial, reorderColumnsForRightFixed, @@ -50,6 +51,48 @@ const rightScroll = (scrollLeft: number) => ({ 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[] = [ diff --git a/packages/components/table/hooks/useFixed.ts b/packages/components/table/hooks/useFixed.ts index 392ae645bf..d032f6d056 100644 --- a/packages/components/table/hooks/useFixed.ts +++ b/packages/components/table/hooks/useFixed.ts @@ -10,10 +10,9 @@ import usePrevious from '../../hooks/usePrevious'; import { resizeObserverElement } from '../utils'; import { buildFixedLayoutState, markFixedColumnBoundaries } from '../utils/markFixedColumnBoundaries'; import { - createFixedLayoutScrollMetrics, - getColumnsTotalWidth, getDeferredRightFixedStickyColKeys, hasFixedColumnNeedReorder, + readFixedLayoutScrollMetricsFromElement, resolveDisplayColumnsForFixed, resolveRightBorderBoundaryColKey, shouldShowLeftFixedColumnShadow, @@ -153,20 +152,8 @@ export default function useFixed( tableElmRef.current = val; } - const getFixedLayoutScrollMetrics = () => { - const el = tableContentRef.current; - if (!el) return createFixedLayoutScrollMetrics(0, 0, 0); - const metrics = createFixedLayoutScrollMetrics(el.scrollLeft, el.scrollWidth, el.clientWidth); - if (metrics.maxScrollLeft > 0 || !hasFixedColumnNeedReorder(originColumns)) { - return metrics; - } - // 列 fixed 切换后 DOM scrollWidth 可能尚未更新,用列宽总和兜底 - const colWidths = thWidthList.current; - const totalWidth = getColumnsTotalWidth(originColumns, colWidths); - const fallbackMax = Math.max(0, totalWidth - el.clientWidth); - if (fallbackMax <= 0) return metrics; - return { scrollLeft: el.scrollLeft, maxScrollLeft: fallbackMax }; - }; + const getFixedLayoutScrollMetrics = () => + readFixedLayoutScrollMetricsFromElement(tableContentRef.current, originColumns, thWidthList.current); const calculateThWidthList = (trList: HTMLCollection) => { const widthMap: { [colKey: string]: number } = {}; diff --git a/packages/components/table/utils/reorderFixedColumns.ts b/packages/components/table/utils/reorderFixedColumns.ts index fc6a86b5fd..c569a7f5e9 100644 --- a/packages/components/table/utils/reorderFixedColumns.ts +++ b/packages/components/table/utils/reorderFixedColumns.ts @@ -99,6 +99,25 @@ export function createFixedLayoutScrollMetrics( }; } +/** + * 从滚动容器读取横向滚动度量;列 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 { From 603c446d3e1824466511e33432e8963b8d3ce9c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Mon, 29 Jun 2026 09:50:52 +0800 Subject: [PATCH 10/10] =?UTF-8?q?chore(pr-compressed-size):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=AE=89=E8=A3=85=E8=84=9A=E6=9C=AC=E4=BB=A5=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E9=94=81=E5=AE=9A=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-compressed-size.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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