Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 27 additions & 26 deletions src/FixedHolder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ function useColumnWidth(colWidths: readonly number[], columCount: number) {
export interface FixedHeaderProps<RecordType> extends HeaderProps<RecordType> {
className: string;
style?: React.CSSProperties;
noData: boolean;
maxContentScroll: boolean;
colWidths: readonly number[];
columCount: number;
Expand All @@ -40,7 +39,6 @@ export interface FixedHeaderProps<RecordType> extends HeaderProps<RecordType> {
tableLayout?: TableLayout;
onScroll: (info: { currentTarget: HTMLDivElement; scrollLeft?: number }) => void;
children: (info: HeaderProps<RecordType>) => React.ReactNode;
colGroup?: React.ReactNode;
}

const FixedHolder = React.forwardRef<HTMLDivElement, FixedHeaderProps<any>>((props, ref) => {
Expand All @@ -51,11 +49,9 @@ const FixedHolder = React.forwardRef<HTMLDivElement, FixedHeaderProps<any>>((pro
const {
className,
style,
noData,
columns,
flattenColumns,
colWidths,
colGroup,
columCount,
stickyOffsets,
direction,
Expand All @@ -81,6 +77,11 @@ const FixedHolder = React.forwardRef<HTMLDivElement, FixedHeaderProps<any>>((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<HTMLDivElement>(null);
Expand Down Expand Up @@ -133,13 +134,13 @@ const FixedHolder = React.forwardRef<HTMLDivElement, FixedHeaderProps<any>>((pro
};

const columnsWithScrollbar = useMemo<ColumnsType<unknown>>(
() => (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
Expand All @@ -159,13 +160,17 @@ const FixedHolder = React.forwardRef<HTMLDivElement, FixedHeaderProps<any>>((pro

const mergedColumnWidth = useColumnWidth(colWidths, columCount);

const isColGroupEmpty = useMemo<boolean>(() => {
// 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 (
<div
Expand All @@ -182,20 +187,16 @@ const FixedHolder = React.forwardRef<HTMLDivElement, FixedHeaderProps<any>>((pro
<TableComponent
style={{
tableLayout,
minWidth: '100%',
minWidth: scrollbarAdjustedWidth || '100%',
// https://github.com/ant-design/ant-design/issues/54894
width: scrollX,
width: scrollX ?? scrollbarAdjustedWidth,
}}
>
{isColGroupEmpty ? (
colGroup
) : (
<ColGroup
colWidths={[...mergedColumnWidth, combinationScrollBarSize]}
columCount={columCount + 1}
columns={flattenColumnsWithScrollbar}
/>
)}
<ColGroup
colWidths={headerColumnWidths}
columCount={headerColumns.length}
columns={headerColumns}
/>
{children({
...restProps,
stickyOffsets: headerStickyOffsets,
Expand Down
9 changes: 6 additions & 3 deletions src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,11 @@ const Table = <RecordType extends DefaultRecordType>(
}
}, []);

const emptyBodyTableStyle: React.CSSProperties =
fixHeader && !horizonScroll && !hasData && scrollbarSize
? { width: `calc(100% - ${scrollbarSize}px)` }
: {};

// ================== INTERNAL HOOKS ==================
React.useEffect(() => {
if (useInternalHooks && internalRefs) {
Expand Down Expand Up @@ -720,6 +725,7 @@ const Table = <RecordType extends DefaultRecordType>(
<TableComponent
style={{
...scrollTableStyle,
...emptyBodyTableStyle,
tableLayout: mergedTableLayout,
}}
{...ariaProps}
Expand All @@ -739,7 +745,6 @@ const Table = <RecordType extends DefaultRecordType>(

// Fixed holder share the props
const fixedHolderProps = {
noData: !mergedData.length,
maxContentScroll: horizonScroll && mergedScrollX === 'max-content',
...headerProps,
...columnContext,
Expand All @@ -759,7 +764,6 @@ const Table = <RecordType extends DefaultRecordType>(
stickyTopOffset={offsetHeader}
className={`${prefixCls}-header`}
ref={scrollHeaderRef}
colGroup={bodyColGroup}
>
{renderFixedHeaderTable}
</FixedHolder>
Expand All @@ -775,7 +779,6 @@ const Table = <RecordType extends DefaultRecordType>(
stickyBottomOffset={offsetSummary}
className={`${prefixCls}-summary`}
ref={scrollSummaryRef}
colGroup={bodyColGroup}
>
{renderFixedFooterTable}
</FixedHolder>
Expand Down
195 changes: 195 additions & 0 deletions tests/FixedHeader.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(<Table columns={columns} data={[]} scroll={{ y: 100 }} />);

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(
<Table columns={columns} data={[]} scroll={{ y: 100 }} />,
);

await safeAct();
const emptyColumnCount = getHeaderColumns(container).length;

visible = true;
rerender(<Table columns={columns} data={data} scroll={{ y: 100 }} />);
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(
<Table columns={columns} data={data} scroll={{ y: 100 }} />,
);

await measureColumns(container);
const populatedWidths = getColumnWidths(getHeaderColumns(container));

rerender(<Table columns={columns} data={[]} scroll={{ y: 100 }} />);
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(
<Table columns={columns} data={[]} sticky scroll={{ x: 300 }} />,
);

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(
<Table
columns={columns}
data={[]}
scroll={{ y: 100 }}
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}>Total</Table.Summary.Cell>
<Table.Summary.Cell index={1}>0</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>,
);

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(
<Table columns={columns} data={[]} scroll={{ x: 1200, y: 100 }} />,
);

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(
<Table
Expand Down
Loading