diff --git a/docs/demo/expandedSticky.md b/docs/demo/expandedSticky.md new file mode 100644 index 000000000..df58f5783 --- /dev/null +++ b/docs/demo/expandedSticky.md @@ -0,0 +1,8 @@ +--- +title: expandedSticky +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/expandedSticky.tsx b/docs/examples/expandedSticky.tsx new file mode 100644 index 000000000..0469db326 --- /dev/null +++ b/docs/examples/expandedSticky.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import type { ColumnType } from 'rc-table'; +import Table from 'rc-table'; +import '../../assets/index.less'; + +const Demo = () => { + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + + const data = [ + { key: 'a', a: '小二', d: '文零西路' }, + { key: 'b', a: '张三', d: '文一西路' }, + { key: 'c', a: '张三', d: '文二西路' }, + ]; + + const columns: ColumnType>[] = [ + { + title: '手机号', + dataIndex: 'a', + width: 100, + onCell: (_, index) => { + if (index === 1) { + return { + rowSpan: 2, + }; + } else if (index === 2) { + return { + rowSpan: 0, + }; + } + }, + }, + { title: 'key', dataIndex: 'key2', width: 100 }, + Table.EXPAND_COLUMN, + { title: 'key', dataIndex: 'key' }, + { title: 'Address', fixed: 'right', dataIndex: 'd', width: 200 }, + ]; + + return ( +
+

expanded & sticky

+ > + rowKey="key" + sticky + scroll={{ x: 2000 }} + columns={columns} + data={data} + expandable={{ + expandedRowOffset: 2, + expandedRowKeys, + onExpandedRowsChange: keys => setExpandedRowKeys(keys), + expandedRowRender: record =>

expandedRowRender: {record.key}

, + }} + className="table" + /> +
+ ); +}; + +export default Demo; diff --git a/src/Body/BodyRow.tsx b/src/Body/BodyRow.tsx index 46ca866f8..4badca944 100644 --- a/src/Body/BodyRow.tsx +++ b/src/Body/BodyRow.tsx @@ -19,6 +19,14 @@ export interface BodyRowProps { scopeCellComponent: CustomizeComponent; indent?: number; rowKey: React.Key; + rowKeys: React.Key[]; + + // Expanded Row + expandedRowInfo?: { + offset: number; + colSpan: number; + sticky: number; + }; } // ================================================================================== @@ -30,6 +38,8 @@ export function getCellProps( colIndex: number, indent: number, index: number, + rowKeys: React.Key[] = [], + expandedRowOffset = 0, ) { const { record, @@ -43,6 +53,8 @@ export function getCellProps( expanded, hasNestChildren, onTriggerExpand, + expandable, + expandedKeys, } = rowInfo; const key = columnsKey[colIndex]; @@ -68,16 +80,32 @@ export function getCellProps( ); } - let additionalCellProps: React.TdHTMLAttributes; - if (column.onCell) { - additionalCellProps = column.onCell(record, index); + const additionalCellProps = column.onCell?.(record, index) || {}; + + // Expandable row has offset + if (expandedRowOffset) { + const { rowSpan = 1 } = additionalCellProps; + + // For expandable row with rowSpan, + // We should increase the rowSpan if the row is expanded + if (expandable && rowSpan && colIndex < expandedRowOffset) { + let currentRowSpan = rowSpan; + + for (let i = index; i < index + rowSpan; i += 1) { + const rowKey = rowKeys[i]; + if (expandedKeys.has(rowKey)) { + currentRowSpan += 1; + } + } + additionalCellProps.rowSpan = currentRowSpan; + } } return { key, fixedInfo, appendCellNode, - additionalCellProps: additionalCellProps || {}, + additionalCellProps: additionalCellProps, }; } @@ -98,10 +126,12 @@ function BodyRow( index, renderIndex, rowKey, + rowKeys, indent = 0, rowComponent: RowComponent, cellComponent, scopeCellComponent, + expandedRowInfo, } = props; const rowInfo = useRowInfo(record, rowKey, index, indent); const { @@ -153,6 +183,8 @@ function BodyRow( colIndex, indent, index, + rowKeys, + expandedRowInfo?.offset, ); return ( @@ -195,7 +227,8 @@ function BodyRow( prefixCls={prefixCls} component={RowComponent} cellComponent={cellComponent} - colSpan={flattenColumns.length} + colSpan={expandedRowInfo ? expandedRowInfo.colSpan : flattenColumns.length} + stickyOffset={expandedRowInfo?.sticky} isEmpty={false} > {expandContent} diff --git a/src/Body/ExpandedRow.tsx b/src/Body/ExpandedRow.tsx index b4009601c..425df8647 100644 --- a/src/Body/ExpandedRow.tsx +++ b/src/Body/ExpandedRow.tsx @@ -14,6 +14,7 @@ export interface ExpandedRowProps { children: React.ReactNode; colSpan: number; isEmpty: boolean; + stickyOffset?: number; } function ExpandedRow(props: ExpandedRowProps) { @@ -30,6 +31,7 @@ function ExpandedRow(props: ExpandedRowProps) { expanded, colSpan, isEmpty, + stickyOffset = 0, } = props; const { scrollbarSize, fixHeader, fixColumn, componentWidth, horizonScroll } = useContext( @@ -44,9 +46,9 @@ function ExpandedRow(props: ExpandedRowProps) { contentNode = (
(props: BodyProps) { expandedKeys, childrenColumnName, emptyNode, + expandedRowOffset = 0, + colWidths, } = useContext(TableContext, [ 'prefixCls', 'getComponent', @@ -40,16 +42,42 @@ function Body(props: BodyProps) { 'expandedKeys', 'childrenColumnName', 'emptyNode', + 'expandedRowOffset', + 'fixedInfoList', + 'colWidths', ]); - const flattenData: { record: RecordType; indent: number; index: number }[] = - useFlattenRecords(data, childrenColumnName, expandedKeys, getRowKey); + const flattenData = useFlattenRecords( + data, + childrenColumnName, + expandedKeys, + getRowKey, + ); + const rowKeys = React.useMemo(() => flattenData.map(item => item.rowKey), [flattenData]); // =================== Performance ==================== const perfRef = React.useRef({ renderWithProps: false, }); + // ===================== Expanded ===================== + // `expandedRowOffset` data is same for all the rows. + // Let's calc on Body side to save performance. + const expandedRowInfo = React.useMemo(() => { + const expandedColSpan = flattenColumns.length - expandedRowOffset; + + let expandedStickyStart = 0; + for (let i = 0; i < expandedRowOffset; i += 1) { + expandedStickyStart += colWidths[i] || 0; + } + + return { + offset: expandedRowOffset, + colSpan: expandedColSpan, + sticky: expandedStickyStart, + }; + }, [flattenColumns.length, expandedRowOffset, colWidths]); + // ====================== Render ====================== const WrapperComponent = getComponent(['body', 'wrapper'], 'tbody'); const trComponent = getComponent(['body', 'row'], 'tr'); @@ -59,14 +87,13 @@ function Body(props: BodyProps) { let rows: React.ReactNode; if (data.length) { rows = flattenData.map((item, idx) => { - const { record, indent, index: renderIndex } = item; - - const key = getRowKey(record, idx); + const { record, indent, index: renderIndex, rowKey } = item; return ( (props: BodyProps) { cellComponent={tdComponent} scopeCellComponent={thComponent} indent={indent} + // Expanded row info + expandedRowInfo={expandedRowInfo} /> ); }); diff --git a/src/Table.tsx b/src/Table.tsx index 187d0ea32..b61de7520 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -822,6 +822,7 @@ function Table( expandableType, expandRowByClick: expandableConfig.expandRowByClick, expandedRowRender: expandableConfig.expandedRowRender, + expandedRowOffset: expandableConfig.expandedRowOffset, onTriggerExpand, expandIconColumnIndex: expandableConfig.expandIconColumnIndex, indentSize: expandableConfig.indentSize, @@ -832,6 +833,7 @@ function Table( columns, flattenColumns, onColumnResize, + colWidths, // Row hoverStartRow: startRow, @@ -872,6 +874,7 @@ function Table( expandableType, expandableConfig.expandRowByClick, expandableConfig.expandedRowRender, + expandableConfig.expandedRowOffset, onTriggerExpand, expandableConfig.expandIconColumnIndex, expandableConfig.indentSize, @@ -881,6 +884,7 @@ function Table( columns, flattenColumns, onColumnResize, + colWidths, // Row startRow, diff --git a/src/VirtualTable/VirtualCell.tsx b/src/VirtualTable/VirtualCell.tsx index 9b1b3ebe5..7f0af164c 100644 --- a/src/VirtualTable/VirtualCell.tsx +++ b/src/VirtualTable/VirtualCell.tsx @@ -56,6 +56,7 @@ function VirtualCell(props: VirtualCellProps) { const { columnsOffset } = useContext(GridContext, ['columnsOffset']); + // TODO: support `expandableRowOffset` const { key, fixedInfo, appendCellNode, additionalCellProps } = getCellProps( rowInfo, column, diff --git a/src/context/TableContext.tsx b/src/context/TableContext.tsx index f566c84f0..d15807dde 100644 --- a/src/context/TableContext.tsx +++ b/src/context/TableContext.tsx @@ -3,6 +3,7 @@ import type { ColumnsType, ColumnType, Direction, + ExpandableConfig, ExpandableType, ExpandedRowRender, GetComponent, @@ -56,6 +57,7 @@ export interface TableContextProps { columns: ColumnsType; flattenColumns: readonly ColumnType[]; onColumnResize: (columnKey: React.Key, width: number) => void; + colWidths: number[]; // Row hoverStartRow: number; @@ -68,6 +70,8 @@ export interface TableContextProps { childrenColumnName: string; rowHoverable?: boolean; + + expandedRowOffset: ExpandableConfig['expandedRowOffset']; } const TableContext = createContext(); diff --git a/src/hooks/useColumns/index.tsx b/src/hooks/useColumns/index.tsx index 573a44af3..9f3f4b760 100644 --- a/src/hooks/useColumns/index.tsx +++ b/src/hooks/useColumns/index.tsx @@ -122,6 +122,7 @@ function useColumns( expandIcon, rowExpandable, expandIconColumnIndex, + expandedRowOffset = 0, direction, expandRowByClick, columnWidth, @@ -146,6 +147,7 @@ function useColumns( clientWidth: number; fixed?: FixedType; scrollWidth?: number; + expandedRowOffset?: number; }, transformColumns: (columns: ColumnsType) => ColumnsType, ): [ @@ -236,7 +238,16 @@ function useColumns( }, }; - return cloneColumns.map(col => (col === EXPAND_COLUMN ? expandColumn : col)); + return cloneColumns.map((col, index) => { + const column = col === EXPAND_COLUMN ? expandColumn : col; + if (index < expandedRowOffset) { + return { + ...column, + fixed: column.fixed || 'left', + }; + } + return column; + }); } if (process.env.NODE_ENV !== 'production' && baseColumns.includes(EXPAND_COLUMN)) { @@ -245,7 +256,7 @@ function useColumns( return baseColumns.filter(col => col !== EXPAND_COLUMN); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [expandable, baseColumns, getRowKey, expandedKeys, expandIcon, direction]); + }, [expandable, baseColumns, getRowKey, expandedKeys, expandIcon, direction, expandedRowOffset]); // ========================= Transform ======================== const mergedColumns = React.useMemo(() => { diff --git a/src/hooks/useFlattenRecords.ts b/src/hooks/useFlattenRecords.ts index ff67f5d9d..7f6f816db 100644 --- a/src/hooks/useFlattenRecords.ts +++ b/src/hooks/useFlattenRecords.ts @@ -11,14 +11,15 @@ function fillRecords( getRowKey: GetRowKey, index: number, ) { + const key = getRowKey(record, index); + list.push({ record, indent, index, + rowKey: key, }); - const key = getRowKey(record); - const expanded = expandedKeys?.has(key); if (record && Array.isArray(record[childrenColumnName]) && expanded) { @@ -41,6 +42,7 @@ export interface FlattenData { record: RecordType; indent: number; index: number; + rowKey: Key; } /** @@ -80,6 +82,7 @@ export default function useFlattenRecords( record: item, indent: 0, index, + rowKey: getRowKey(item, index), }; }); }, [data, childrenColumnName, expandedKeys, getRowKey]); diff --git a/src/interface.ts b/src/interface.ts index e645b2145..c6d9a1794 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -252,6 +252,7 @@ export interface ExpandableConfig { rowExpandable?: (record: RecordType) => boolean; columnWidth?: number | string; fixed?: FixedType; + expandedRowOffset?: number; } // =================== Render =================== diff --git a/tests/ExpandedOffset.spec.tsx b/tests/ExpandedOffset.spec.tsx new file mode 100644 index 000000000..1c6fa1191 --- /dev/null +++ b/tests/ExpandedOffset.spec.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import { render, act } from '@testing-library/react'; +import { _rs } from 'rc-resize-observer'; +import Table, { type ColumnsType } from '../src'; + +async function triggerResize(ele: HTMLElement) { + await act(async () => { + _rs([{ target: ele }] as any); + await Promise.resolve(); + }); +} + +describe('Table.ExpandedOffset', () => { + let domSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + beforeAll(() => { + domSpy = spyElementPrototypes(HTMLElement, { + offsetParent: { + get: () => ({}), + }, + offsetWidth: { + get: () => 50, + }, + }); + }); + + afterAll(() => { + domSpy.mockRestore(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('expanded + sticky', async () => { + const columns: ColumnsType = [ + { + title: 'a', + // `fixed` will auto patch to fill the space + // fixed: 'left', + }, + Table.EXPAND_COLUMN, + { title: 'b' }, + { title: 'c' }, + ]; + + const data = [{ key: 'a' }]; + const { container } = render( + > + columns={columns} + data={data} + sticky + scroll={{ x: 1200 }} + expandable={{ + expandedRowOffset: 1, + defaultExpandAllRows: true, + expandedRowRender: record =>
{record.key}
, + }} + />, + ); + + await triggerResize(container.querySelector('.rc-table')); + + act(() => { + const coll = container.querySelector('.rc-table-resize-collection'); + if (coll) { + triggerResize(coll as HTMLElement); + } + }); + + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + + expect(container.querySelector('.rc-table-expanded-row .rc-table-cell')).toHaveAttribute( + 'colspan', + '3', + ); + expect(container.querySelector('.rc-table-expanded-row .rc-table-cell div')).toHaveStyle({ + position: 'sticky', + left: '50px', + }); + }); +});