From 34f5eade724bcc28c45202a218f9db852b82fe3d Mon Sep 17 00:00:00 2001 From: linxianxi <904492381@qq.com> Date: Sat, 11 May 2024 10:47:58 +0800 Subject: [PATCH] feat: columnResizable --- README.md | 2 + assets/index.less | 30 ++++ docs/examples/column-resize.tsx | 183 +++++++++++---------- package.json | 1 - src/Body/MeasureCell.tsx | 6 +- src/Body/MeasureRow.tsx | 16 +- src/Body/index.tsx | 6 +- src/Cell/index.tsx | 1 - src/Header/HeaderCell.tsx | 31 ++++ src/Header/HeaderRow.tsx | 34 +++- src/Header/useCellResize.tsx | 134 +++++++++++++++ src/Table.tsx | 29 +++- src/VirtualTable/BodyGrid.tsx | 6 +- src/context/TableContext.tsx | 12 +- src/interface.ts | 10 +- tests/Resizable.spec.jsx | 277 ++++++++++++++++++++++++++++++++ 16 files changed, 667 insertions(+), 111 deletions(-) create mode 100644 src/Header/HeaderCell.tsx create mode 100644 src/Header/useCellResize.tsx create mode 100644 tests/Resizable.spec.jsx diff --git a/README.md b/README.md index ad889ead2..df32ab8ad 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,11 @@ React.render(, mountNode); | title | React Node | | title of this column | | dataIndex | String | | display field of the data record | | width | String \| Number | | width of the specific proportion calculation according to the width of the columns | +| minWidth | Number | | min width of the specific proportion calculation according to the width of the columns | | fixed | String \| Boolean | | this column will be fixed when table scroll horizontally: true or 'left' or 'right' | | align | String | | specify how cell content is aligned | | ellipsis | Boolean | | specify whether cell content be ellipsized | +| resizable | Boolean | | column resize | | rowScope | 'row' \| 'rowgroup' | | Set scope attribute for all cells in this column | | onCell | Function(record, index) | | Set custom props per each cell. | | onHeaderCell | Function(record) | | Set custom props per each header cell. | diff --git a/assets/index.less b/assets/index.less index bfa245b03..039780527 100644 --- a/assets/index.less +++ b/assets/index.less @@ -63,9 +63,39 @@ } } + &-column-resizing { + cursor: col-resize; + } + &-cell { background: #f4f4f4; + &-resize-handle { + position: absolute; + top: 0; + right: 0; + width: 4px; + height: 100%; + cursor: col-resize; + z-index: 1; + background: red; + } + + &-resize-line { + position: absolute; + width: 4px; + background: red; + height: 100%; + top: 0; + transform: translateX(-50%); + z-index: 2; + } + + + &-fix-right &-resize-handle { + left: 0; + } + &-fix-left, &-fix-right { z-index: 2; diff --git a/docs/examples/column-resize.tsx b/docs/examples/column-resize.tsx index a3f15cbbc..81b33bb42 100644 --- a/docs/examples/column-resize.tsx +++ b/docs/examples/column-resize.tsx @@ -1,95 +1,106 @@ -import React from 'react'; -import { Resizable } from 'react-resizable'; -import Table from 'rc-table'; +import React, { useState } from 'react'; +import Table, { INTERNAL_HOOKS } from 'rc-table'; +import type { ColumnType } from 'rc-table'; import '../../assets/index.less'; -import 'react-resizable/css/styles.css'; -import type { ColumnType } from '@/interface'; -const ResizableTitle = props => { - const { onResize, width, ...restProps } = props; +const data = [ + { a: '123', b: 'xxxxxxxx xxxxxxxx', d: 3, key: '1' }, + { a: 'cdd', b: 'edd12221 edd12221', d: 3, key: '2' }, + { a: '133', c: 'edd12221 edd12221', d: 2, key: '3' }, + { a: '133', c: 'edd12221 edd12221', d: 2, key: '4' }, + { a: '133', c: 'edd12221 edd12221', d: 2, key: '5' }, + { a: '133', c: 'edd12221 edd12221', d: 2, key: '6' }, + { a: '133', c: 'edd12221 edd12221', d: 2, key: '7' }, + { a: '133', c: 'edd12221 edd12221', d: 2, key: '8' }, + { a: '133', c: 'edd12221 edd12221', d: 2, key: '9' }, +]; - if (!width) { - return
; - } +const Demo = () => { + const [widthMap, setWidthMap] = useState>(new Map()); + + const columns1 = [ + { title: 'title1', dataIndex: 'aaa', key: 'aaa', width: 100 }, + { title: 'title2', dataIndex: 'bbb', key: 'bbb', width: 100 }, + ].map(i => ({ + ...i, + resizable: true, + width: widthMap.get(i.key ?? i.dataIndex) ?? i.width, + })) as ColumnType[]; + + const columns2 = [ + { title: 'title1', dataIndex: 'a', key: 'a', fixed: 'left' }, + { title: 'title2', dataIndex: 'b', key: 'b', fixed: 'left' }, + { title: 'title3', dataIndex: 'c', key: 'c' }, + { title: 'title4', dataIndex: 'b', key: 'd' }, + { title: 'title5', dataIndex: 'b', key: 'e' }, + { title: 'title6', dataIndex: 'b', key: 'f' }, + { title: 'title7', dataIndex: 'b', key: 'g' }, + { title: 'title8', dataIndex: 'b', key: 'h' }, + { title: 'title9', dataIndex: 'b', key: 'i' }, + { title: 'title10', dataIndex: 'b', key: 'j' }, + { title: 'title11', dataIndex: 'b', key: 'k', fixed: 'right' }, + { title: 'title12', dataIndex: 'b', key: 'l', fixed: 'right' }, + ].map(i => ({ + ...i, + resizable: true, + width: widthMap.get(i.key ?? i.dataIndex) ?? 150, + })) as ColumnType[]; return ( - - - +
+ table width: 800px {'columns=[{width: 100, width: 100}]'} 情况 + t + (c.width as number), 0) }} + columns={columns1} + data={data} + onColumnResizeComplete={({ columnWidths }) => { + setWidthMap(prev => { + const result = new Map(prev); + columnWidths.forEach(i => { + result.set(i.columnKey, i.width); + }); + return result; + }); + }} + internalHooks={INTERNAL_HOOKS} + getContainerWidth={(ele, width) => { + // Minus border + const borderWidth = getComputedStyle( + ele.querySelector('.rc-table-body'), + ).borderInlineStartWidth; + const mergedWidth = width - parseInt(borderWidth, 10); + return mergedWidth; + }} + /> +
+ 大多数情况 +
t + (c.width as number), 0) }} + columns={columns2} + data={data} + onColumnResizeComplete={({ columnWidths }) => { + setWidthMap(prev => { + const result = new Map(prev); + columnWidths.forEach(i => { + result.set(i.columnKey, i.width); + }); + return result; + }); + }} + internalHooks={INTERNAL_HOOKS} + getContainerWidth={(ele, width) => { + // Minus border + const borderWidth = getComputedStyle( + ele.querySelector('.rc-table-body'), + ).borderInlineStartWidth; + const mergedWidth = width - parseInt(borderWidth, 10); + return mergedWidth; + }} + /> + ); }; -interface RecordType { - a: string; - b?: string; - c?: string; - d?: number; - key: string; -} - -interface DemoState { - columns: ColumnType[]; -} - -class Demo extends React.Component<{}, DemoState> { - state: DemoState = { - columns: [ - { title: 'title1', dataIndex: 'a', key: 'a', width: 100 }, - { title: 'title2', dataIndex: 'b', key: 'b', width: 100 }, - { title: 'title3', dataIndex: 'c', key: 'c', width: 200 }, - { - title: 'Operations', - dataIndex: '', - key: 'd', - render() { - return Operations; - }, - }, - ], - }; - - components = { - header: { - cell: ResizableTitle, - }, - }; - - data = [ - { a: '123', key: '1' }, - { a: 'cdd', b: 'edd', key: '2' }, - { a: '1333', c: 'eee', d: 2, key: '3' }, - ]; - - handleResize = - index => - (e, { size }) => { - this.setState(({ columns }) => { - const nextColumns = [...columns]; - nextColumns[index] = { - ...nextColumns[index], - width: size.width, - }; - return { columns: nextColumns }; - }); - }; - - render() { - const columns = this.state.columns.map((col, index) => ({ - ...col, - onHeaderCell: (column: ColumnType) => - ({ - width: column.width, - onResize: this.handleResize(index), - }) as any, - })); - - return ( -
-

Integrate with react-resizable

-
- - ); - } -} - export default Demo; diff --git a/package.json b/package.json index 3e68adf93..68707792e 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,6 @@ "react-dnd": "^2.5.4", "react-dnd-html5-backend": "^2.5.4", "react-dom": "^16.0.0", - "react-resizable": "^3.0.5", "react-virtualized": "^9.12.0", "react-window": "^1.8.5", "regenerator-runtime": "^0.14.0", diff --git a/src/Body/MeasureCell.tsx b/src/Body/MeasureCell.tsx index 1fc8d9cdf..a1e482f3a 100644 --- a/src/Body/MeasureCell.tsx +++ b/src/Body/MeasureCell.tsx @@ -3,15 +3,15 @@ import ResizeObserver from 'rc-resize-observer'; export interface MeasureCellProps { columnKey: React.Key; - onColumnResize: (key: React.Key, width: number) => void; + onColumnWidthChange: (key: React.Key, width: number) => void; } -export default function MeasureCell({ columnKey, onColumnResize }: MeasureCellProps) { +export default function MeasureCell({ columnKey, onColumnWidthChange }: MeasureCellProps) { const cellRef = React.useRef(); React.useEffect(() => { if (cellRef.current) { - onColumnResize(columnKey, cellRef.current.offsetWidth); + onColumnWidthChange(columnKey, cellRef.current.offsetWidth); } }, []); diff --git a/src/Body/MeasureRow.tsx b/src/Body/MeasureRow.tsx index 3dbd12113..48d60f40e 100644 --- a/src/Body/MeasureRow.tsx +++ b/src/Body/MeasureRow.tsx @@ -4,11 +4,15 @@ import MeasureCell from './MeasureCell'; export interface MeasureCellProps { prefixCls: string; - onColumnResize: (key: React.Key, width: number) => void; + onColumnWidthChange: (key: React.Key, width: number) => void; columnsKey: React.Key[]; } -export default function MeasureRow({ prefixCls, columnsKey, onColumnResize }: MeasureCellProps) { +export default function MeasureRow({ + prefixCls, + columnsKey, + onColumnWidthChange, +}: MeasureCellProps) { return ( { infoList.forEach(({ data: columnKey, size }) => { - onColumnResize(columnKey, size.offsetWidth); + onColumnWidthChange(columnKey, size.offsetWidth); }); }} > {columnsKey.map(columnKey => ( - + ))} diff --git a/src/Body/index.tsx b/src/Body/index.tsx index 3173fa22d..5a0e0f4a2 100644 --- a/src/Body/index.tsx +++ b/src/Body/index.tsx @@ -25,7 +25,7 @@ function Body(props: BodyProps) { const { prefixCls, getComponent, - onColumnResize, + onColumnWidthChange, flattenColumns, getRowKey, expandedKeys, @@ -34,7 +34,7 @@ function Body(props: BodyProps) { } = useContext(TableContext, [ 'prefixCls', 'getComponent', - 'onColumnResize', + 'onColumnWidthChange', 'flattenColumns', 'getRowKey', 'expandedKeys', @@ -104,7 +104,7 @@ function Body(props: BodyProps) { )} diff --git a/src/Cell/index.tsx b/src/Cell/index.tsx index 90bb04a48..370e749a4 100644 --- a/src/Cell/index.tsx +++ b/src/Cell/index.tsx @@ -116,7 +116,6 @@ function Cell(props: CellProps) { additionalProps = {}, isSticky, } = props; - const cellPrefixCls = `${prefixCls}-cell`; const { supportSticky, allColumnsFixedLeft, rowHoverable } = useContext(TableContext, [ 'supportSticky', diff --git a/src/Header/HeaderCell.tsx b/src/Header/HeaderCell.tsx new file mode 100644 index 000000000..c6c9c129d --- /dev/null +++ b/src/Header/HeaderCell.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import type { CellProps } from '../Cell'; +import Cell from '../Cell'; +import useCellResize from './useCellResize'; +import { useContext } from '@rc-component/context'; +import TableContext from '../context/TableContext'; + +interface HeaderCellProps extends CellProps { + columnKey?: React.Key; + resizable?: boolean; + minWidth?: number; +} + +function HeaderCell({ + columnKey, + resizable, + minWidth, + ...cellProps +}: HeaderCellProps) { + const { supportSticky } = useContext(TableContext, ['supportSticky']); + + const { fixRight, prefixCls } = cellProps; + const isFixRight = typeof fixRight === 'number' && supportSticky; + const cellPrefixCls = `${prefixCls}-cell`; + + const resizeHandleNode = useCellResize(columnKey, isFixRight, cellPrefixCls, resizable, minWidth); + + return ; +} + +export default HeaderCell; diff --git a/src/Header/HeaderRow.tsx b/src/Header/HeaderRow.tsx index fe7629c64..43480aac1 100644 --- a/src/Header/HeaderRow.tsx +++ b/src/Header/HeaderRow.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import Cell from '../Cell'; import TableContext from '../context/TableContext'; import { useContext } from '@rc-component/context'; import type { @@ -11,9 +10,10 @@ import type { } from '../interface'; import { getCellFixedInfo } from '../utils/fixUtil'; import { getColumnsKey } from '../utils/valueUtil'; +import HeaderCell from './HeaderCell'; export interface RowProps { - cells: readonly CellType[]; + cells: CellType[]; stickyOffsets: StickyOffsets; flattenColumns: readonly ColumnType[]; rowComponent: CustomizeComponent; @@ -60,8 +60,29 @@ const HeaderRow = (props: RowProps) => { additionalProps = cell.column.onHeaderCell(column); } + // If the cell is the previous cell of the scrollbar and resizable, and fixed is not right, then the scrollbar is resizable + const isScrollBarCellAndResizable = + column.scrollbar && + (cells[cells.length - 2].column as ColumnType).resizable && + cells[cells.length - 2].column.fixed !== 'right'; + + // Whether this cell is in the previous cell of the scrollbar + const isScrollBarCellPreviousCell = + cells[cells.length - 1].column.scrollbar && cellIndex === cells.length - 2; + + let resizable: boolean; + if (isScrollBarCellPreviousCell) { + if (column.fixed === 'right') { + resizable = (column as ColumnType).resizable; + } else { + resizable = false; + } + } else { + resizable = isScrollBarCellAndResizable || (column as ColumnType).resizable; + } + return ( - 1 ? 'colgroup' : 'col') : null} ellipsis={column.ellipsis} @@ -72,6 +93,13 @@ const HeaderRow = (props: RowProps) => { {...fixedInfo} additionalProps={additionalProps} rowType="header" + columnKey={ + isScrollBarCellAndResizable + ? columnsKey[columnsKey.length - 2] + : columnsKey[cellIndex] + } + resizable={resizable} + minWidth={(column as ColumnType).minWidth} /> ); })} diff --git a/src/Header/useCellResize.tsx b/src/Header/useCellResize.tsx new file mode 100644 index 000000000..c0105cd1b --- /dev/null +++ b/src/Header/useCellResize.tsx @@ -0,0 +1,134 @@ +import TableContext from '../context/TableContext'; +import { useContext } from '@rc-component/context'; +import { useEvent } from 'rc-util'; +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +export default function useCelResize( + columnKey: React.Key, + isFixRight: boolean, + cellPrefixCls: string, + resizable?: boolean, + minWidth: number = 0, +) { + const { + colsWidths, + colsKeys, + colWidths, + componentWidth, + fullTableRef, + scrollbarSize, + onColumnResizeComplete, + onResizingChange, + } = useContext(TableContext, [ + 'colWidths', + 'colsKeys', + 'colsWidths', + 'componentWidth', + 'fullTableRef', + 'scrollbarSize', + 'onColumnResizeComplete', + 'onResizingChange', + ]); + const [isResizing, setIsResizing] = useState(false); + const [lineLeft, setLineLeft] = useState(0); + const lineStartLeftRef = useRef(0); + const startRealWidth = useRef(0); + const startPageX = useRef(0); + const mouseMoveRef = useRef<(event: MouseEvent) => void>(null); + const mouseUpRef = useRef<(event: MouseEvent) => void>(null); + + const removeResizeListener = () => { + document.body.removeEventListener('mousemove', mouseMoveRef.current); + document.body.removeEventListener('mouseup', mouseUpRef.current); + }; + + useEffect(() => removeResizeListener, []); + + const onResize = useEvent((event: MouseEvent, isResizeEnd?: boolean) => { + const offset = event.pageX - startPageX.current; + const oldWidth = colsWidths.get(columnKey); + let newWidth = startRealWidth.current + (isFixRight ? -offset : offset); + + if (newWidth < minWidth) { + newWidth = minWidth; + } + setLineLeft( + lineStartLeftRef.current + + (isFixRight ? startRealWidth.current - newWidth : newWidth - startRealWidth.current), + ); + + if (isResizeEnd) { + const totalWidth = colWidths.reduce((total, width) => total + width, 0); + const smallThanWidth = componentWidth - scrollbarSize - (totalWidth - oldWidth + newWidth); + // If it is less than the width of the table, the remaining width will be allocated to the column on the right. + // If there is no column on the right, it will be allocated to the column on the left. + let addWidthColumnKey: React.Key; + const isDecreasingWidth = oldWidth - newWidth > 0; + if (smallThanWidth > 0 && isDecreasingWidth) { + const index = colsKeys.findIndex(key => key === columnKey); + addWidthColumnKey = colsKeys[index + 1] ?? colsKeys[index - 1]; + } + + const columnWidthsMap = new Map(colsWidths); + columnWidthsMap.set(columnKey, newWidth); + if (addWidthColumnKey) { + const addWidthColumnNewWidth = colsWidths.get(addWidthColumnKey) + smallThanWidth; + columnWidthsMap.set(addWidthColumnKey, addWidthColumnNewWidth); + } + const columnWidths = Array.from(columnWidthsMap).map(([key, width]) => ({ + columnKey: key, + width, + })); + onColumnResizeComplete?.({ columnKey, width: newWidth, columnWidths }); + } + }); + + const onResizeEnd = (event: MouseEvent) => { + setIsResizing(false); + onResizingChange(false); + removeResizeListener(); + onResize(event, true); + }; + + const onResizeStart = (event: React.MouseEvent) => { + // Block selected text + event.preventDefault(); + const left = + (event.target as HTMLElement).parentElement.getBoundingClientRect()[ + isFixRight ? 'left' : 'right' + ] - fullTableRef.current.getBoundingClientRect().left; + setLineLeft(left); + lineStartLeftRef.current = left; + startRealWidth.current = colsWidths.get(columnKey); + startPageX.current = event.pageX; + document.body.addEventListener('mousemove', onResize); + document.body.addEventListener('mouseup', onResizeEnd); + mouseMoveRef.current = onResize; + mouseUpRef.current = onResizeEnd; + onResizingChange(true); + setIsResizing(true); + }; + + const resizeHandleNode = resizable && ( + <> +
+ {isResizing && + createPortal( +
, + fullTableRef.current, + )} + + ); + + return resizeHandleNode; +} diff --git a/src/Table.tsx b/src/Table.tsx index a8c6344f8..b354c6866 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -125,6 +125,11 @@ export interface TableProps // Events onScroll?: React.UIEventHandler; + onColumnResizeComplete?: (info: { + columnKey: React.Key; + width: number; + columnWidths: { columnKey: React.Key; width: number }[]; + }) => void; // =================================== Internal =================================== /** @@ -211,6 +216,7 @@ function Table( // Events onScroll, + onColumnResizeComplete, // Internal internalHooks, @@ -352,9 +358,11 @@ function Table( const [pingedLeft, setPingedLeft] = React.useState(false); const [pingedRight, setPingedRight] = React.useState(false); const [colsWidths, updateColsWidths] = useLayoutState(new Map()); + const [isResizing, setIsResizing] = React.useState(false); // Convert map to number width - const colsKeys = getColumnsKey(flattenColumns); + const pureColsKeys = getColumnsKey(flattenColumns); + const colsKeys = React.useMemo(() => pureColsKeys, [pureColsKeys.join('_')]); const pureColWidths = colsKeys.map(columnKey => colsWidths.get(columnKey)); const colWidths = React.useMemo(() => pureColWidths, [pureColWidths.join('_')]); const stickyOffsets = useStickyOffsets(colWidths, flattenColumns, direction); @@ -404,7 +412,7 @@ function Table( }; } - const onColumnResize = React.useCallback((columnKey: React.Key, width: number) => { + const onColumnWidthChange = React.useCallback((columnKey: React.Key, width: number) => { if (isVisible(fullTableRef.current)) { updateColsWidths(widths => { if (widths.get(columnKey) !== width) { @@ -770,6 +778,7 @@ function Table( [`${prefixCls}-has-fix-right`]: flattenColumns[flattenColumns.length - 1] && flattenColumns[flattenColumns.length - 1].fixed === 'right', + [`${prefixCls}-column-resizing`]: isResizing, })} style={style} id={id} @@ -826,7 +835,7 @@ function Table( // Column columns, flattenColumns, - onColumnResize, + onColumnWidthChange, // Row hoverStartRow: startRow, @@ -840,6 +849,12 @@ function Table( childrenColumnName: mergedChildrenColumnName, rowHoverable, + fullTableRef, + colsWidths, + colsKeys, + colWidths, + onColumnResizeComplete, + onResizingChange: setIsResizing, }), [ // Scroll @@ -853,6 +868,7 @@ function Table( fixedInfoList, isSticky, supportSticky, + fullTableRef, componentWidth, fixHeader, @@ -875,7 +891,12 @@ function Table( // Column columns, flattenColumns, - onColumnResize, + onColumnWidthChange, + colsWidths, + colsKeys, + colWidths, + onColumnResizeComplete, + setIsResizing, // Row startRow, diff --git a/src/VirtualTable/BodyGrid.tsx b/src/VirtualTable/BodyGrid.tsx index dc9d3cd63..d143e25ab 100644 --- a/src/VirtualTable/BodyGrid.tsx +++ b/src/VirtualTable/BodyGrid.tsx @@ -24,7 +24,7 @@ const Grid = React.forwardRef((props, ref) => { const { flattenColumns, - onColumnResize, + onColumnWidthChange, getRowKey, expandedKeys, prefixCls, @@ -33,7 +33,7 @@ const Grid = React.forwardRef((props, ref) => { scrollX, } = useContext(TableContext, [ 'flattenColumns', - 'onColumnResize', + 'onColumnWidthChange', 'getRowKey', 'prefixCls', 'expandedKeys', @@ -71,7 +71,7 @@ const Grid = React.forwardRef((props, ref) => { React.useEffect(() => { columnsWidth.forEach(([key, width]) => { - onColumnResize(key, width); + onColumnWidthChange(key, width); }); }, [columnsWidth]); diff --git a/src/context/TableContext.tsx b/src/context/TableContext.tsx index fd810b378..7fee91193 100644 --- a/src/context/TableContext.tsx +++ b/src/context/TableContext.tsx @@ -55,7 +55,7 @@ export interface TableContextProps { // Column columns: ColumnsType; flattenColumns: readonly ColumnType[]; - onColumnResize: (columnKey: React.Key, width: number) => void; + onColumnWidthChange: (columnKey: React.Key, width: number) => void; // Row hoverStartRow: number; @@ -68,6 +68,16 @@ export interface TableContextProps { childrenColumnName: string; rowHoverable?: boolean; + fullTableRef: React.MutableRefObject; + colsWidths: Map; + colWidths: number[]; + colsKeys: React.Key[]; + onColumnResizeComplete?: (info: { + columnKey: React.Key; + width: number; + columnWidths: { columnKey: React.Key; width: number }[]; + }) => void; + onResizingChange: (value: boolean) => void; } const TableContext = createContext(); diff --git a/src/interface.ts b/src/interface.ts index 06213c043..5b20aa1de 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -50,7 +50,7 @@ export interface CellType { className?: string; style?: React.CSSProperties; children?: React.ReactNode; - column?: ColumnsType[number]; + column?: ColumnsType[number] & { scrollbar?: boolean }; colSpan?: number; rowSpan?: number; @@ -70,7 +70,11 @@ export type Direction = 'ltr' | 'rtl'; // SpecialString will be removed in antd@6 export type SpecialString = T | (string & {}); -export type DataIndex = DeepNamePath | SpecialString | number | (SpecialString | number)[]; +export type DataIndex = + | DeepNamePath + | SpecialString + | number + | (SpecialString | number)[]; export type CellEllipsisType = { showTitle?: boolean } | boolean; @@ -109,6 +113,8 @@ export interface ColumnType extends ColumnSharedType { shouldCellUpdate?: (record: RecordType, prevRecord: RecordType) => boolean; rowSpan?: number; width?: number | string; + minWidth?: number; + resizable?: boolean; onCell?: GetComponentProps; /** @deprecated Please use `onCell` instead */ onCellClick?: (record: RecordType, e: React.MouseEvent) => void; diff --git a/tests/Resizable.spec.jsx b/tests/Resizable.spec.jsx new file mode 100644 index 000000000..f4e91eee0 --- /dev/null +++ b/tests/Resizable.spec.jsx @@ -0,0 +1,277 @@ +import { mount } from 'enzyme'; +import Table from '../src'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import RcResizeObserver, { _rs } from 'rc-resize-observer'; +import { safeAct } from './utils'; +import { spyElementPrototype } from 'rc-util/lib/test/domHook'; + +describe('Table.resizable', () => { + let domSpy; + let containerSpy; + + beforeAll(() => { + domSpy = spyElementPrototype(HTMLElement, 'offsetParent', { + get: () => ({}), + }); + containerSpy = spyElementPrototype(HTMLDivElement, 'offsetWidth', { + get: () => 800, + }); + }); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + domSpy.mockRestore(); + containerSpy.mockRestore(); + }); + + it('change width in onColumnResizeComplete', async () => { + const onColumnResizeComplete = vi.fn(); + + const App = () => { + const [widthMap, setWidthMap] = React.useState(new Map()); + + const columns = [ + { key: 'a', dataIndex: 'a', width: 400, resizable: true }, + { key: 'b', dataIndex: 'b', width: 400, resizable: true }, + ].map(col => ({ ...col, width: widthMap.get(col.key ?? col.dataIndex) || col.width })); + + return ( +
t + c.width, 0) }} + onColumnResizeComplete={info => { + setWidthMap(prev => { + const result = new Map(prev); + info.columnWidths.forEach(i => { + result.set(i.columnKey, i.width); + }); + return result; + }); + onColumnResizeComplete(info); + }} + /> + ); + }; + const wrapper = mount(); + + async function triggerResize(resizeList) { + wrapper.find(RcResizeObserver.Collection).first().props().onBatchResize(resizeList); + await safeAct(wrapper); + wrapper.update(); + } + + await triggerResize([ + { + data: wrapper.find('ResizeObserver').at(1).props().data, + size: { width: 400, offsetWidth: 400 }, + }, + { + data: wrapper.find('ResizeObserver').at(2).props().data, + size: { width: 400, offsetWidth: 400 }, + }, + ]); + + wrapper.find('.rc-table-cell-resize-handle').first().simulate('mousedown', { pageX: 0 }); + + const mousemoveEvent = new Event('mousemove'); + mousemoveEvent.pageX = 100; + + await act(async () => { + document.body.dispatchEvent(mousemoveEvent); + await Promise.resolve(); + wrapper.update(); + }); + + const mouseupEvent = new Event('mouseup'); + mouseupEvent.pageX = 100; + + await act(async () => { + document.body.dispatchEvent(mouseupEvent); + await Promise.resolve(); + wrapper.update(); + }); + + expect(onColumnResizeComplete).toHaveBeenCalledWith({ + columnKey: 'a', + width: 500, + columnWidths: [ + { columnKey: 'a', width: 500 }, + { columnKey: 'b', width: 400 }, + ], + }); + + expect(wrapper.find('colgroup col').at(0).props().style.width).toBe(500); + expect(wrapper.find('colgroup col').at(1).props().style.width).toBe(400); + }); + + it('columns total width < componentWidth', async () => { + const onColumnResizeComplete = vi.fn(); + + const App = () => { + const [widthMap, setWidthMap] = React.useState(new Map()); + + const columns = [ + { key: 'a', dataIndex: 'a', width: 100, resizable: true }, + { key: 'b', dataIndex: 'b', width: 100, resizable: true }, + ].map(col => ({ ...col, width: widthMap.get(col.key ?? col.dataIndex) || col.width || 100 })); + + return ( +
t + c.width, 0) }} + onColumnResizeComplete={info => { + setWidthMap(prev => { + const result = new Map(prev); + info.columnWidths.forEach(i => { + result.set(i.columnKey, i.width); + }); + return result; + }); + onColumnResizeComplete(info); + }} + /> + ); + }; + const wrapper = mount(); + + async function triggerResize(resizeList) { + wrapper.find(RcResizeObserver.Collection).first().props().onBatchResize(resizeList); + await safeAct(wrapper); + wrapper.update(); + } + + wrapper.find(RcResizeObserver).first().props().onResize({ width: 800 }); + + await triggerResize([ + { + data: wrapper.find('ResizeObserver').at(1).props().data, + size: { width: 400, offsetWidth: 400 }, + }, + { + data: wrapper.find('ResizeObserver').at(2).props().data, + size: { width: 400, offsetWidth: 400 }, + }, + ]); + + wrapper.find('.rc-table-cell-resize-handle').first().simulate('mousedown', { pageX: 0 }); + + const mousemoveEvent = new Event('mousemove'); + mousemoveEvent.pageX = -100; + + await act(async () => { + document.body.dispatchEvent(mousemoveEvent); + await Promise.resolve(); + wrapper.update(); + }); + + const mouseupEvent = new Event('mouseup'); + mouseupEvent.pageX = -100; + + await act(async () => { + document.body.dispatchEvent(mouseupEvent); + await Promise.resolve(); + wrapper.update(); + }); + + expect(onColumnResizeComplete).toHaveBeenCalledWith({ + columnKey: 'a', + width: 300, + columnWidths: [ + { columnKey: 'a', width: 300 }, + // scrollBarSize = 15px + { columnKey: 'b', width: 485 }, + ], + }); + + expect(wrapper.find('colgroup col').at(0).props().style.width).toBe(300); + expect(wrapper.find('colgroup col').at(1).props().style.width).toBe(485); + }); + + it('minWidth should be worked', async () => { + const onColumnResizeComplete = vi.fn(); + + const App = () => { + const [widthMap, setWidthMap] = React.useState(new Map()); + + const columns = [ + { key: 'a', dataIndex: 'a', width: 800, resizable: true, minWidth: 400 }, + { key: 'b', dataIndex: 'b', width: 800, resizable: true }, + ].map(col => ({ ...col, width: widthMap.get(col.key ?? col.dataIndex) || col.width })); + + return ( +
t + c.width, 0) }} + onColumnResizeComplete={info => { + setWidthMap(prev => { + const result = new Map(prev); + info.columnWidths.forEach(i => { + result.set(i.columnKey, i.width); + }); + return result; + }); + onColumnResizeComplete(info); + }} + /> + ); + }; + const wrapper = mount(); + + async function triggerResize(resizeList) { + wrapper.find(RcResizeObserver.Collection).first().props().onBatchResize(resizeList); + await safeAct(wrapper); + wrapper.update(); + } + + await triggerResize([ + { + data: wrapper.find('ResizeObserver').at(1).props().data, + size: { width: 800, offsetWidth: 800 }, + }, + { + data: wrapper.find('ResizeObserver').at(2).props().data, + size: { width: 800, offsetWidth: 800 }, + }, + ]); + + wrapper.find('.rc-table-cell-resize-handle').first().simulate('mousedown', { pageX: 0 }); + + const mousemoveEvent = new Event('mousemove'); + mousemoveEvent.pageX = -1000; + + await act(async () => { + document.body.dispatchEvent(mousemoveEvent); + await Promise.resolve(); + wrapper.update(); + }); + + const mouseupEvent = new Event('mouseup'); + mouseupEvent.pageX = -1000; + + await act(async () => { + document.body.dispatchEvent(mouseupEvent); + await Promise.resolve(); + wrapper.update(); + }); + + expect(onColumnResizeComplete).toHaveBeenCalledWith({ + columnKey: 'a', + width: 400, + columnWidths: [ + { columnKey: 'a', width: 400 }, + { columnKey: 'b', width: 800 }, + ], + }); + + expect(wrapper.find('colgroup col').at(0).props().style.width).toBe(400); + expect(wrapper.find('colgroup col').at(1).props().style.width).toBe(800); + }); +});