diff --git a/src/FixedHolder/index.tsx b/src/FixedHolder/index.tsx index fdcc1132f..fa2d8a4c3 100644 --- a/src/FixedHolder/index.tsx +++ b/src/FixedHolder/index.tsx @@ -27,7 +27,6 @@ function useColumnWidth(colWidths: readonly number[], columCount: number) { export interface FixedHeaderProps extends HeaderProps { className: string; style?: React.CSSProperties; - noData: boolean; maxContentScroll: boolean; colWidths: readonly number[]; columCount: number; @@ -40,7 +39,6 @@ export interface FixedHeaderProps extends HeaderProps { tableLayout?: TableLayout; onScroll: (info: { currentTarget: HTMLDivElement; scrollLeft?: number }) => void; children: (info: HeaderProps) => React.ReactNode; - colGroup?: React.ReactNode; } const FixedHolder = React.forwardRef>((props, ref) => { @@ -51,11 +49,9 @@ const FixedHolder = React.forwardRef>((pro const { className, style, - noData, columns, flattenColumns, colWidths, - colGroup, columCount, stickyOffsets, direction, @@ -81,6 +77,11 @@ const FixedHolder = React.forwardRef>((pro const TableComponent = getComponent(['header', 'table'], 'table'); const combinationScrollBarSize = isSticky && !fixHeader ? 0 : scrollbarSize; + const hasScrollbarColumn = combinationScrollBarSize > 0; + const scrollbarAdjustedWidth = + hasScrollbarColumn && scrollX == null + ? `calc(100% - ${combinationScrollBarSize}px)` + : undefined; // Pass wheel to scroll event const scrollRef = React.useRef(null); @@ -133,13 +134,13 @@ const FixedHolder = React.forwardRef>((pro }; const columnsWithScrollbar = useMemo>( - () => (combinationScrollBarSize ? [...columns, ScrollBarColumn] : columns), - [combinationScrollBarSize, columns], + () => (hasScrollbarColumn ? [...columns, ScrollBarColumn] : columns), + [hasScrollbarColumn, columns], ); const flattenColumnsWithScrollbar = useMemo( - () => (combinationScrollBarSize ? [...flattenColumns, ScrollBarColumn] : flattenColumns), - [combinationScrollBarSize, flattenColumns], + () => (hasScrollbarColumn ? [...flattenColumns, ScrollBarColumn] : flattenColumns), + [hasScrollbarColumn, flattenColumns], ); // Calculate the sticky offsets @@ -159,13 +160,17 @@ const FixedHolder = React.forwardRef>((pro const mergedColumnWidth = useColumnWidth(colWidths, columCount); - const isColGroupEmpty = useMemo(() => { - // use original ColGroup if no data or no calculated column width, otherwise use calculated column width - // Return original colGroup if no data, or mergedColumnWidth is empty, or all widths are falsy - const noWidth = - !mergedColumnWidth || !mergedColumnWidth.length || mergedColumnWidth.every(w => !w); - return noData || noWidth; - }, [noData, mergedColumnWidth]); + const noMeasuredColumnWidth = + !mergedColumnWidth || + mergedColumnWidth.length !== columCount || + mergedColumnWidth.every(width => !width); + const baseColumnWidths = noMeasuredColumnWidth + ? flattenColumns.map(({ width }) => width) + : mergedColumnWidth; + const headerColumnWidths = hasScrollbarColumn + ? [...baseColumnWidths, combinationScrollBarSize] + : baseColumnWidths; + const headerColumns = hasScrollbarColumn ? flattenColumnsWithScrollbar : flattenColumns; return (
>((pro - {isColGroupEmpty ? ( - colGroup - ) : ( - - )} + {children({ ...restProps, stickyOffsets: headerStickyOffsets, diff --git a/src/Table.tsx b/src/Table.tsx index 26f9cf3f4..92093e14d 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -593,6 +593,11 @@ const Table = ( } }, []); + const emptyBodyTableStyle: React.CSSProperties = + fixHeader && !horizonScroll && !hasData && scrollbarSize + ? { width: `calc(100% - ${scrollbarSize}px)` } + : {}; + // ================== INTERNAL HOOKS ================== React.useEffect(() => { if (useInternalHooks && internalRefs) { @@ -720,6 +725,7 @@ const Table = ( ( // Fixed holder share the props const fixedHolderProps = { - noData: !mergedData.length, maxContentScroll: horizonScroll && mergedScrollX === 'max-content', ...headerProps, ...columnContext, @@ -759,7 +764,6 @@ const Table = ( stickyTopOffset={offsetHeader} className={`${prefixCls}-header`} ref={scrollHeaderRef} - colGroup={bodyColGroup} > {renderFixedHeaderTable} @@ -775,7 +779,6 @@ const Table = ( stickyBottomOffset={offsetSummary} className={`${prefixCls}-summary`} ref={scrollSummaryRef} - colGroup={bodyColGroup} > {renderFixedFooterTable} diff --git a/tests/FixedHeader.spec.jsx b/tests/FixedHeader.spec.jsx index 29add88f3..1314d2369 100644 --- a/tests/FixedHeader.spec.jsx +++ b/tests/FixedHeader.spec.jsx @@ -12,6 +12,26 @@ async function triggerResize(ele) { }); } +async function measureColumns(container) { + const measureCells = container.querySelectorAll('.rc-table-measure-row td'); + for (const measureCell of measureCells) { + await triggerResize(measureCell); + } + await safeAct(); +} + +function getHeaderColumns(container) { + return Array.from(container.querySelectorAll('.rc-table-header colgroup col')); +} + +function getHeaderLeafCells(container) { + return Array.from(container.querySelectorAll('.rc-table-header thead tr:last-child th')); +} + +function getColumnWidths(columns) { + return columns.map(column => column.style.width); +} + describe('Table.FixedHeader', () => { let domSpy; let visible = true; @@ -91,6 +111,181 @@ describe('Table.FixedHeader', () => { expect(tables[0].querySelector('colgroup col').className).toEqual('test-internal'); }); + describe('scrollbar column model', () => { + it('keeps the initial empty header ColGroup aligned with the scrollbar header cell', async () => { + visible = false; + const columns = [ + { + title: 'Selection', + dataIndex: 'selection', + width: 48, + [INTERNAL_COL_DEFINE]: { + className: 'selection-column', + columnType: 'SELECTION_COLUMN', + }, + }, + { title: 'Name', dataIndex: 'name' }, + { title: 'Age', dataIndex: 'age', width: 80 }, + ]; + const { container } = render(); + + await safeAct(); + + const headerColumns = getHeaderColumns(container); + const headerLeafCells = getHeaderLeafCells(container); + + expect(headerColumns).toHaveLength(headerLeafCells.length); + expect(headerColumns).toHaveLength(columns.length + 1); + expect(headerColumns[0]).toHaveClass('selection-column'); + expect(headerColumns[0]).toHaveStyle({ width: '48px' }); + expect(headerColumns[1].style.width).toBeFalsy(); + expect(headerColumns[2]).toHaveStyle({ width: '80px' }); + expect(headerColumns.at(-1)).toHaveStyle({ width: '15px' }); + expect(headerLeafCells.at(-1)).toHaveClass('rc-table-cell-scrollbar'); + expect(container.querySelector('.rc-table-header table')).toHaveStyle({ + minWidth: 'calc(100% - 15px)', + width: 'calc(100% - 15px)', + }); + expect(container.querySelector('.rc-table-body table')).toHaveStyle({ + width: 'calc(100% - 15px)', + }); + }); + + it('keeps the scrollbar column structure when empty data becomes populated', async () => { + visible = false; + const columns = [ + { title: 'Name', dataIndex: 'name', width: 120 }, + { title: 'Age', dataIndex: 'age' }, + { title: 'Address', dataIndex: 'address', width: 160 }, + ]; + const data = [{ key: 1, name: 'Light', age: 18, address: 'Bamboo' }]; + const { container, rerender } = render( +
, + ); + + await safeAct(); + const emptyColumnCount = getHeaderColumns(container).length; + + visible = true; + rerender(
); + await measureColumns(container); + + const populatedHeaderColumns = getHeaderColumns(container); + expect(emptyColumnCount).toBe(columns.length + 1); + expect(populatedHeaderColumns).toHaveLength(emptyColumnCount); + expect(populatedHeaderColumns.at(-1)).toHaveStyle({ width: '15px' }); + expect(getHeaderLeafCells(container)).toHaveLength(emptyColumnCount); + }); + + it('retains measured business column widths when populated data becomes empty', async () => { + measureWidth = 137; + const columns = [ + { title: 'Name', dataIndex: 'name', width: 120 }, + { title: 'Age', dataIndex: 'age' }, + { title: 'Address', dataIndex: 'address', width: 160 }, + ]; + const data = [{ key: 1, name: 'Light', age: 18, address: 'Bamboo' }]; + const { container, rerender } = render( +
, + ); + + await measureColumns(container); + const populatedWidths = getColumnWidths(getHeaderColumns(container)); + + rerender(
); + await safeAct(); + const emptyWidths = getColumnWidths(getHeaderColumns(container)); + + expect(populatedWidths).toEqual(['137px', '137px', '137px', '15px']); + expect(emptyWidths).toEqual(populatedWidths); + expect(getHeaderLeafCells(container)).toHaveLength(emptyWidths.length); + }); + + it('does not append a scrollbar column when the combined scrollbar size is zero', async () => { + visible = false; + const columns = [ + { title: 'Name', dataIndex: 'name', width: 120 }, + { title: 'Age', dataIndex: 'age', width: 80 }, + ]; + const { container } = render( +
, + ); + + await safeAct(); + + expect(getHeaderColumns(container)).toHaveLength(columns.length); + expect(getHeaderLeafCells(container)).toHaveLength(columns.length); + expect(container.querySelector('.rc-table-header .rc-table-cell-scrollbar')).toBeFalsy(); + expect(container.querySelector('.rc-table-header table')).toHaveStyle({ minWidth: '100%' }); + }); + + it('uses the same scrollbar ColGroup structure for fixed header and fixed summary', async () => { + visible = false; + const columns = [ + { title: 'Name', dataIndex: 'name', width: 120 }, + { title: 'Age', dataIndex: 'age', width: 80 }, + ]; + const { container } = render( +
( + + + Total + 0 + + + )} + />, + ); + + await safeAct(); + + const headerColumns = getHeaderColumns(container); + const summaryColumns = Array.from( + container.querySelectorAll('div.rc-table-summary colgroup col'), + ); + const bodyColumns = container.querySelectorAll('.rc-table-body colgroup col'); + const summaryCells = container.querySelectorAll('div.rc-table-summary tfoot td'); + + expect(headerColumns).toHaveLength(columns.length + 1); + expect(summaryColumns).toHaveLength(headerColumns.length); + expect(getColumnWidths(summaryColumns)).toEqual(getColumnWidths(headerColumns)); + expect(bodyColumns).toHaveLength(columns.length); + expect(summaryCells).toHaveLength(columns.length); + expect(summaryCells[summaryCells.length - 1]).toHaveAttribute('colspan', '2'); + }); + + it('preserves the empty-data fallback for many columns without declared widths', async () => { + visible = false; + const columns = Array.from({ length: 12 }, (_, index) => ({ + title: `Column ${index + 1}`, + dataIndex: `field${index + 1}`, + key: `field${index + 1}`, + ...(index < 2 || index === 11 ? { width: 100 } : {}), + })); + const { container } = render( +
, + ); + + await safeAct(); + + const headerColumns = getHeaderColumns(container); + const businessColumns = headerColumns.slice(0, -1); + + expect(headerColumns).toHaveLength(columns.length + 1); + expect(getHeaderLeafCells(container)).toHaveLength(columns.length + 1); + expect(businessColumns).toHaveLength(columns.length); + expect(businessColumns[0]).toHaveStyle({ width: '100px' }); + expect(businessColumns[1]).toHaveStyle({ width: '100px' }); + expect(businessColumns[2].style.width).toBeFalsy(); + expect(businessColumns.at(-1)).toHaveStyle({ width: '100px' }); + expect(headerColumns.at(-1)).toHaveStyle({ width: '15px' }); + }); + }); + it('rtl', async () => { const { container } = render(
renders correctly > scrollXY - without data 1`] = ` > - - - - - - - - - + + + + + + + + + + custom components > renders fixed column and header corre
+ + + + + + should get scrollbar size 1`] = ` style="overflow: hidden;" >