From eac5ce83f510ebc1ae4ec767cc8f78749ebb8806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Fri, 25 Aug 2023 20:06:58 +0800 Subject: [PATCH] feat: Support virtual Table (#1012) * chore: init * chore: static of columns * feat: use virtual * refactor: extract func * chore: use internal hook * test: rm useless test * chore: support fixed * chore: scroll support * chore: scroll sync * chore: fixed pos * chore: fix style * chore: opt flatten * chore: part of it * chore: collect rows * chore: collect rowSpan * refactor: virtual cell * chore: rowSpan support * feat: rowSpan & colSpan * chore: row & col span * chore: update cal * fix: edge logic * chore: use fixed line * chore: use line align * chore: bump rc-virtual-list * chore: clean up * chore: refactor name * chore: use create immutable * chore: clean up * chore: bump rc-virtual-list * refactor: move useRowInfo in a single file * chore: tmp of columns * chore: fix sticky expand * refactor: move hooks * refactor: move hooks * chore: expanded support * chore: row expandable * chore: refactor name * refactor: row in * refactor: move rowClass in * chore: expanded info * chore: virtual * Update package.json Co-authored-by: Amumu * chore: update span logic * 7.33.0-alpha.0 * feat: support listItemHeight * 7.33.0-alpha.1 * chore: nest of cell in row * 7.33.0-alpha.2 * fix: not render if no data * 7.33.0-alpha.3 * chore: add empty style * 7.33.0-alpha.4 * test: update testcase --------- Co-authored-by: Amumu --- assets/index.less | 8 +- assets/virtual.less | 30 +++ docs/demo/virtual.md | 8 + docs/examples/virtual.tsx | 217 ++++++++++++++++ package.json | 9 +- src/Body/BodyRow.tsx | 191 +++++++------- src/Body/index.tsx | 35 ++- src/Cell/index.tsx | 3 +- src/Cell/useCellRender.ts | 2 +- src/Footer/index.tsx | 6 +- src/Header/Header.tsx | 4 +- src/Table.tsx | 81 +++--- src/VirtualTable/BodyGrid.tsx | 237 ++++++++++++++++++ src/VirtualTable/BodyLine.tsx | 136 ++++++++++ src/VirtualTable/VirtualCell.tsx | 126 ++++++++++ src/VirtualTable/context.ts | 15 ++ src/VirtualTable/index.tsx | 84 +++++++ src/context/TableContext.tsx | 14 +- .../{useColumns.tsx => useColumns/index.tsx} | 29 ++- src/hooks/useColumns/useWidthColumns.tsx | 67 +++++ src/hooks/useFixedInfo.ts | 11 +- src/hooks/useFlattenRecords.ts | 32 +-- src/hooks/useRowInfo.tsx | 123 +++++++++ src/index.ts | 5 + src/interface.ts | 9 +- tests/FixedHeader.spec.jsx | 52 ++-- tests/Table.spec.jsx | 93 ++++--- tests/Virtual.spec.tsx | 183 ++++++++++++++ 28 files changed, 1534 insertions(+), 276 deletions(-) create mode 100644 assets/virtual.less create mode 100644 docs/demo/virtual.md create mode 100644 docs/examples/virtual.tsx create mode 100644 src/VirtualTable/BodyGrid.tsx create mode 100644 src/VirtualTable/BodyLine.tsx create mode 100644 src/VirtualTable/VirtualCell.tsx create mode 100644 src/VirtualTable/context.ts create mode 100644 src/VirtualTable/index.tsx rename src/hooks/{useColumns.tsx => useColumns/index.tsx} (91%) create mode 100644 src/hooks/useColumns/useWidthColumns.tsx create mode 100644 src/hooks/useRowInfo.tsx create mode 100644 tests/Virtual.spec.tsx diff --git a/assets/index.less b/assets/index.less index 0167af415..f00920ba6 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,3 +1,5 @@ +@import 'virtual.less'; + @tablePrefixCls: rc-table; @text-color: #666; @font-size-base: 12px; @@ -55,9 +57,11 @@ // ================== Cell ================== &-cell { + background: #f4f4f4; + &-fix-left, &-fix-right { - z-index: 1; + z-index: 2; } &-fix-right:last-child:not(&-fix-sticky) { @@ -137,7 +141,7 @@ } &&-row-hover { - background: rgba(255, 0, 0, 0.05); + background: #fff4f4; } } diff --git a/assets/virtual.less b/assets/virtual.less new file mode 100644 index 000000000..e1f7ca52e --- /dev/null +++ b/assets/virtual.less @@ -0,0 +1,30 @@ +@import (reference) 'index.less'; + +.@{tablePrefixCls}-tbody-virtual { + * { + box-sizing: border-box; + } + + @border: 1px solid @border-color; + border-left: @border; + + .@{tablePrefixCls}-row { + display: flex; + box-sizing: border-box; + width: 100%; + } + + .@{tablePrefixCls}-row-extra { + .@{tablePrefixCls}-cell { + background: rgba(200, 200, 255) !important; + } + } + + .@{tablePrefixCls}-cell { + flex: 0 0 var(--virtual-width); + width: var(--virtual-width); + padding: 8px 16px; + border-right: @border; + border-bottom: @border; + } +} diff --git a/docs/demo/virtual.md b/docs/demo/virtual.md new file mode 100644 index 000000000..b9372a03b --- /dev/null +++ b/docs/demo/virtual.md @@ -0,0 +1,8 @@ +--- +title: Virtual +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/virtual.tsx b/docs/examples/virtual.tsx new file mode 100644 index 000000000..a161d549e --- /dev/null +++ b/docs/examples/virtual.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import '../../assets/index.less'; +import type { ColumnsType } from '../../src/interface'; +import { VirtualTable } from '../../src'; + +interface RecordType { + a: string; + b?: string; + c?: string; + d: number; + indexKey: string; +} + +const columns: ColumnsType = [ + { title: 'title1', dataIndex: 'a', key: 'a', width: 100, fixed: 'left' }, + { title: 'title2', dataIndex: 'b', key: 'b', width: 100, fixed: 'left', ellipsis: true }, + { + title: 'title3', + dataIndex: 'c', + key: 'c', + onCell: (_, index) => { + if (index % 4 === 0) { + return { + rowSpan: 3, + }; + } + + if (index % 4 === 3) { + return { + rowSpan: 1, + colSpan: 3, + }; + } + + return { + rowSpan: 0, + }; + }, + }, + { + title: 'title4', + key: 'd', + children: [ + // Children columns + { + title: 'title4-1', + dataIndex: 'b', + onCell: (_, index) => { + if (index % 4 === 0) { + return { + colSpan: 3, + }; + } + + if (index % 4 === 3) { + return { + colSpan: 0, + }; + } + }, + }, + { + title: 'title4-2', + dataIndex: 'b', + onCell: (_, index) => { + if (index % 4 === 0 || index % 4 === 3) { + return { + colSpan: 0, + }; + } + }, + }, + ], + }, + { + title: 'title6', + dataIndex: 'b', + key: 'f', + onCell: (_, index) => { + if (index % 4 === 0) { + return { + rowSpan: 0, + colSpan: 0, + }; + } + + if (index % 4 === 1) { + return { + rowSpan: 3, + }; + } + + return { + rowSpan: 0, + }; + }, + }, + { + title: ( +
+ title7 +
+
+
+ Hello world! +
+ ), + dataIndex: 'bk', + key: 'g', + }, + { + title: 'title8', + dataIndex: 'b', + onCell: (_, index) => { + if (index % 2 === 0) { + return { + rowSpan: 2, + colSpan: 2, + }; + } + + return { + rowSpan: 0, + }; + }, + }, + { + title: 'title9 i', + dataIndex: 'b', + key: 'i', + onCell: () => ({ + colSpan: 0, + }), + }, + { title: 'title10', dataIndex: 'b', key: 'j' }, + { + title: 'title11', + dataIndex: 'b', + key: 'k', + width: 50, + fixed: 'right', + onCell: (_, index) => { + return { + rowSpan: index % 2 === 0 ? 2 : 0, + // colSpan: 2, + }; + }, + }, + { + title: 'title12', + dataIndex: 'b', + key: 'l', + width: 100, + fixed: 'right', + onCell: () => { + return { + // colSpan: 0, + }; + }, + }, +]; + +export function cleanOnCell(cols: any = []) { + cols.forEach(col => { + delete (col as any).onCell; + + cleanOnCell((col as any).children); + }); +} +cleanOnCell(columns); + +const data: RecordType[] = new Array(4 * 10000).fill(null).map((_, index) => ({ + a: `a${index}`, + b: `b${index}`, + c: `c${index}`, + d: index, + bk: Hello, + indexKey: `${index}`, + // children: [ + // { + // indexKey: `${index}-1`, + // }, + // { + // indexKey: `${index}-2`, + // }, + // ], +})); + +const Demo = () => { + const [scrollY, setScrollY] = React.useState(true); + + return ( +
+ + b || c} + scroll={{ x: 1200, y: scrollY ? 200 : null }} + data={data} + // data={[]} + rowKey="indexKey" + expandable={{ + expandedRowRender: () => 2333, + columnWidth: 60, + expandedRowClassName: () => 'good-one', + }} + // onRow={() => ({ className: 'rowed' })} + rowClassName="nice-try" + /> +
+ ); +}; + +export default Demo; diff --git a/package.json b/package.json index 0200aac8f..74ace049d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-table", - "version": "7.32.3", + "version": "7.33.0-alpha.4", "description": "table ui component for react", "engines": { "node": ">=8.x" @@ -42,7 +42,7 @@ "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"", "test": "vitest", "coverage": "vitest run --coverage", - "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish", + "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish --branch static-table", "lint": "eslint src/ --ext .tsx,.ts", "lint:tsc": "tsc -p tsconfig.json --noEmit", "now-build": "npm run docs:build", @@ -54,10 +54,11 @@ }, "dependencies": { "@babel/runtime": "^7.10.1", - "@rc-component/context": "^1.3.0", + "@rc-component/context": "^1.4.0", "classnames": "^2.2.5", "rc-resize-observer": "^1.1.0", - "rc-util": "^5.27.1" + "rc-util": "^5.36.0", + "rc-virtual-list": "^3.10.4" }, "devDependencies": { "@rc-component/father-plugin": "^1.0.2", diff --git a/src/Body/BodyRow.tsx b/src/Body/BodyRow.tsx index 9395ab756..baf6f9e5f 100644 --- a/src/Body/BodyRow.tsx +++ b/src/Body/BodyRow.tsx @@ -1,18 +1,11 @@ -import { responseImmutable, useContext } from '@rc-component/context'; import classNames from 'classnames'; import * as React from 'react'; import Cell from '../Cell'; -import TableContext from '../context/TableContext'; +import { responseImmutable } from '../context/TableContext'; import devRenderTimes from '../hooks/useRenderTimes'; -import type { - ColumnType, - CustomizeComponent, - GetComponentProps, - GetRowKey, - Key, -} from '../interface'; -import { getColumnsKey } from '../utils/valueUtil'; +import type { ColumnType, CustomizeComponent, GetRowKey } from '../interface'; import ExpandedRow from './ExpandedRow'; +import useRowInfo from '../hooks/useRowInfo'; export interface BodyRowProps { record: RecordType; @@ -20,18 +13,77 @@ export interface BodyRowProps { renderIndex: number; className?: string; style?: React.CSSProperties; - expandedKeys: Set; rowComponent: CustomizeComponent; cellComponent: CustomizeComponent; scopeCellComponent: CustomizeComponent; - onRow: GetComponentProps; - rowExpandable: (record: RecordType) => boolean; indent?: number; rowKey: React.Key; getRowKey: GetRowKey; - childrenColumnName: string; } +// ================================================================================== +// == getCellProps == +// ================================================================================== +export function getCellProps( + rowInfo: ReturnType>, + column: ColumnType, + colIndex: number, + indent: number, + index: number, +) { + const { + record, + prefixCls, + columnsKey, + fixedInfoList, + expandIconColumnIndex, + nestExpandable, + indentSize, + expandIcon, + expanded, + hasNestChildren, + onTriggerExpand, + } = rowInfo; + + const key = columnsKey[colIndex]; + const fixedInfo = fixedInfoList[colIndex]; + + // ============= Used for nest expandable ============= + let appendCellNode: React.ReactNode; + if (colIndex === (expandIconColumnIndex || 0) && nestExpandable) { + appendCellNode = ( + <> + + {expandIcon({ + prefixCls, + expanded, + expandable: hasNestChildren, + record, + onExpand: onTriggerExpand, + })} + + ); + } + + let additionalCellProps: React.TdHTMLAttributes; + if (column.onCell) { + additionalCellProps = column.onCell(record, index); + } + + return { + key, + fixedInfo, + appendCellNode, + additionalCellProps: additionalCellProps || {}, + }; +} + +// ================================================================================== +// == getCellProps == +// ================================================================================== function BodyRow( props: BodyRowProps, ) { @@ -46,137 +98,62 @@ function BodyRow( index, renderIndex, rowKey, - rowExpandable, - expandedKeys, - onRow, indent = 0, rowComponent: RowComponent, cellComponent, scopeCellComponent, - childrenColumnName, } = props; + const rowInfo = useRowInfo(record, rowKey, index, indent); const { prefixCls, - fixedInfoList, flattenColumns, - expandableType, - expandRowByClick, - onTriggerExpand, - rowClassName, expandedRowClassName, - indentSize, - expandIcon, expandedRowRender, - expandIconColumnIndex, - } = useContext(TableContext, [ - 'prefixCls', - 'fixedInfoList', - 'flattenColumns', - 'expandableType', - 'expandRowByClick', - 'onTriggerExpand', - 'rowClassName', - 'expandedRowClassName', - 'indentSize', - 'expandIcon', - 'expandedRowRender', - 'expandIconColumnIndex', - ]); + rowProps, + + // Misc + expanded, + rowSupportExpand, + } = rowInfo; + const [expandRended, setExpandRended] = React.useState(false); if (process.env.NODE_ENV !== 'production') { devRenderTimes(props); } - const expanded = expandedKeys && expandedKeys.has(rowKey); - React.useEffect(() => { if (expanded) { setExpandRended(true); } }, [expanded]); - const rowSupportExpand = expandableType === 'row' && (!rowExpandable || rowExpandable(record)); - // Only when row is not expandable and `children` exist in record - const nestExpandable = expandableType === 'nest'; - const hasNestChildren = childrenColumnName && record && record[childrenColumnName]; - const mergedExpandable = rowSupportExpand || nestExpandable; - - // ======================== Expandable ========================= - const onExpandRef = React.useRef(onTriggerExpand); - onExpandRef.current = onTriggerExpand; - - const onInternalTriggerExpand = (...args: Parameters) => { - onExpandRef.current(...args); - }; - - // =========================== onRow =========================== - const additionalProps = onRow?.(record, index); - - const onClick: React.MouseEventHandler = (event, ...args) => { - if (expandRowByClick && mergedExpandable) { - onInternalTriggerExpand(record, event); - } - - additionalProps?.onClick?.(event, ...args); - }; - // ======================== Base tr row ======================== - let computeRowClassName: string; - if (typeof rowClassName === 'string') { - computeRowClassName = rowClassName; - } else if (typeof rowClassName === 'function') { - computeRowClassName = rowClassName(record, index, indent); - } - - const columnsKey = getColumnsKey(flattenColumns); const baseRowNode = ( {flattenColumns.map((column: ColumnType, colIndex) => { const { render, dataIndex, className: columnClassName } = column; - const key = columnsKey[colIndex]; - const fixedInfo = fixedInfoList[colIndex]; - - // ============= Used for nest expandable ============= - let appendCellNode: React.ReactNode; - if (colIndex === (expandIconColumnIndex || 0) && nestExpandable) { - appendCellNode = ( - <> - - {expandIcon({ - prefixCls, - expanded, - expandable: hasNestChildren, - record, - onExpand: onInternalTriggerExpand, - })} - - ); - } - - let additionalCellProps: React.HTMLAttributes; - if (column.onCell) { - additionalCellProps = column.onCell(record, index); - } + const { key, fixedInfo, appendCellNode, additionalCellProps } = getCellProps( + rowInfo, + column, + colIndex, + indent, + index, + ); return ( { data: readonly RecordType[]; - getRowKey: GetRowKey; measureColumnWidth: boolean; - expandedKeys: Set; - onRow: GetComponentProps; - rowExpandable: (record: RecordType) => boolean; - emptyNode: React.ReactNode; - childrenColumnName: string; } function Body(props: BodyProps) { @@ -27,22 +20,26 @@ function Body(props: BodyProps) { devRenderTimes(props); } + const { data, measureColumnWidth } = props; + const { - data, + prefixCls, + getComponent, + onColumnResize, + flattenColumns, getRowKey, - measureColumnWidth, expandedKeys, - onRow, - rowExpandable, - emptyNode, childrenColumnName, - } = props; - - const { prefixCls, getComponent, onColumnResize, flattenColumns } = useContext(TableContext, [ + emptyNode, + } = useContext(TableContext, [ 'prefixCls', 'getComponent', 'onColumnResize', 'flattenColumns', + 'getRowKey', + 'expandedKeys', + 'childrenColumnName', + 'emptyNode', ]); const flattenData: { record: RecordType; indent: number; index: number }[] = @@ -76,11 +73,7 @@ function Body(props: BodyProps) { rowComponent={trComponent} cellComponent={tdComponent} scopeCellComponent={thComponent} - expandedKeys={expandedKeys} - onRow={onRow} getRowKey={getRowKey} - rowExpandable={rowExpandable} - childrenColumnName={childrenColumnName} indent={indent} /> ); diff --git a/src/Cell/index.tsx b/src/Cell/index.tsx index fad3ed55f..27f552690 100644 --- a/src/Cell/index.tsx +++ b/src/Cell/index.tsx @@ -143,7 +143,6 @@ function Cell(props: CellProps) { } if (isFixRight) { fixedStyle.position = 'sticky'; - fixedStyle.right = fixRight as number; } @@ -212,9 +211,9 @@ function Cell(props: CellProps) { } const mergedStyle = { + ...fixedStyle, ...additionalProps.style, ...alignStyle, - ...fixedStyle, ...legacyCellProps?.style, }; diff --git a/src/Cell/useCellRender.ts b/src/Cell/useCellRender.ts index acae182a6..825e6c343 100644 --- a/src/Cell/useCellRender.ts +++ b/src/Cell/useCellRender.ts @@ -1,4 +1,3 @@ -import { useImmutableMark } from '@rc-component/context'; import useMemo from 'rc-util/lib/hooks/useMemo'; import isEqual from 'rc-util/lib/isEqual'; import getValue from 'rc-util/lib/utils/get'; @@ -7,6 +6,7 @@ import * as React from 'react'; import PerfContext from '../context/PerfContext'; import type { CellType, ColumnType, DataIndex, RenderedCell } from '../interface'; import { validateValue } from '../utils/valueUtil'; +import { useImmutableMark } from '../context/TableContext'; function isRenderCell( data: React.ReactNode | RenderedCell, diff --git a/src/Footer/index.tsx b/src/Footer/index.tsx index 73c2b51ce..c2e5986a7 100644 --- a/src/Footer/index.tsx +++ b/src/Footer/index.tsx @@ -1,6 +1,6 @@ -import { responseImmutable, useContext } from '@rc-component/context'; +import { useContext } from '@rc-component/context'; import * as React from 'react'; -import TableContext from '../context/TableContext'; +import TableContext, { responseImmutable } from '../context/TableContext'; import devRenderTimes from '../hooks/useRenderTimes'; import type { ColumnsType, ColumnType, StickyOffsets } from '../interface'; import Summary from './Summary'; @@ -32,7 +32,7 @@ function Footer(props: FooterProps) { stickyOffsets, flattenColumns, scrollColumnIndex: scrollColumn?.scrollbar ? lastColumnIndex : null, - columns + columns, }), [scrollColumn, flattenColumns, lastColumnIndex, stickyOffsets, columns], ); diff --git a/src/Header/Header.tsx b/src/Header/Header.tsx index 6ec22086b..d7466d503 100644 --- a/src/Header/Header.tsx +++ b/src/Header/Header.tsx @@ -1,6 +1,6 @@ -import { responseImmutable, useContext } from '@rc-component/context'; +import { useContext } from '@rc-component/context'; import * as React from 'react'; -import TableContext from '../context/TableContext'; +import TableContext, { responseImmutable } from '../context/TableContext'; import devRenderTimes from '../hooks/useRenderTimes'; import type { CellType, diff --git a/src/Table.tsx b/src/Table.tsx index ee51b17dc..74f64ed9b 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -24,7 +24,6 @@ * - All expanded props, move into expandable */ -import { makeImmutable } from '@rc-component/context'; import type { CompareProps } from '@rc-component/context/lib/Immutable'; import classNames from 'classnames'; import ResizeObserver from 'rc-resize-observer'; @@ -39,7 +38,7 @@ import * as React from 'react'; import Body from './Body'; import ColGroup from './ColGroup'; import { EXPAND_COLUMN, INTERNAL_HOOKS } from './constant'; -import TableContext from './context/TableContext'; +import TableContext, { makeImmutable } from './context/TableContext'; import type { FixedHeaderProps } from './FixedHolder'; import FixedHolder from './FixedHolder'; import Footer, { FooterComponents } from './Footer'; @@ -76,6 +75,8 @@ import Column from './sugar/Column'; import ColumnGroup from './sugar/ColumnGroup'; import { getColumnsKey, validateValue } from './utils/valueUtil'; +export const DEFAULT_PREFIX = 'rc-table'; + // Used for conditions cache const EMPTY_DATA = []; @@ -133,6 +134,15 @@ export interface TableProps // Used for antd table transform column with additional column transformColumns?: (columns: ColumnsType) => ColumnsType; + /** + * @private Internal usage, may remove by refactor. + * + * !!! DO NOT USE IN PRODUCTION ENVIRONMENT !!! + */ + // Force trade scrollbar as 0 size. + // Force column to be average width if not set + tailor?: boolean; + /** * @private Internal usage, may remove by refactor. * @@ -152,7 +162,7 @@ function defaultEmpty() { function Table(tableProps: TableProps) { const props = { rowKey: 'key', - prefixCls: 'rc-table', + prefixCls: DEFAULT_PREFIX, emptyText: defaultEmpty, ...tableProps, }; @@ -186,6 +196,7 @@ function Table(tableProps: TableProps(tableProps: TableProps(tableProps: TableProps; + // ====================== Hover ======================= const [startRow, endRow, onHover] = useHover(); @@ -264,8 +279,9 @@ function Table(tableProps: TableProps(tableProps: TableProps(tableProps: TableProps { - if (scrollBodyRef.current instanceof Element) { - setScrollbarSize(getTargetScrollBarSize(scrollBodyRef.current).width); - } else { - setScrollbarSize(getTargetScrollBarSize(scrollBodyContainerRef.current).width); + if (!tailor || !useInternalHooks) { + if (scrollBodyRef.current instanceof Element) { + setScrollbarSize(getTargetScrollBarSize(scrollBodyRef.current).width); + } else { + setScrollbarSize(getTargetScrollBarSize(scrollBodyContainerRef.current).width); + } } setSupportSticky(isStyleSupport('position', 'sticky')); }, []); // ================== INTERNAL HOOKS ================== React.useEffect(() => { - if (internalHooks === INTERNAL_HOOKS && internalRefs) { + if (useInternalHooks && internalRefs) { internalRefs.body.current = scrollBodyRef.current; } }); @@ -517,16 +536,7 @@ function Table(tableProps: TableProps + ); const bodyColGroup = ( @@ -538,17 +548,6 @@ function Table(tableProps: TableProps{caption} ) : undefined; - const customizeScrollBody = getComponent(['body']) as CustomizeScrollBody; - - if ( - process.env.NODE_ENV !== 'production' && - typeof customizeScrollBody === 'function' && - hasData && - !fixHeader - ) { - warning(false, '`components.body` with render props is only work on `scroll.y`.'); - } - const dataProps = pickAttrs(props, { data: true }); const ariaProps = pickAttrs(props, { aria: true }); @@ -564,7 +563,8 @@ function Table(tableProps: TableProps { - const colWidth = index === flattenColumns.length - 1 ? (width as number) - scrollbarSize : width; + const colWidth = + index === flattenColumns.length - 1 ? (width as number) - scrollbarSize : width; if (typeof colWidth === 'number' && !Number.isNaN(colWidth)) { return colWidth; } @@ -745,7 +745,6 @@ function Table(tableProps: TableProps(tableProps: TableProps col.fixed === 'left'), + emptyNode, // Column columns, @@ -767,6 +767,12 @@ function Table(tableProps: TableProps(tableProps: TableProps(tableProps: TableProps { + data: RecordType[]; + onScroll: OnCustomizeScroll; +} + +export interface GridRef { + scrollLeft: number; +} + +const Grid = React.forwardRef((props, ref) => { + const { data, onScroll } = props; + + const { + flattenColumns, + onColumnResize, + getRowKey, + expandedKeys, + prefixCls, + childrenColumnName, + emptyNode, + } = useContext(TableContext, [ + 'flattenColumns', + 'onColumnResize', + 'getRowKey', + 'prefixCls', + 'expandedKeys', + 'childrenColumnName', + 'emptyNode', + ]); + const { scrollY, scrollX, listItemHeight } = useContext(StaticContext); + + // =========================== Ref ============================ + const listRef = React.useRef(); + + // =========================== Data =========================== + const flattenData = useFlattenRecords(data, childrenColumnName, expandedKeys, getRowKey); + + // ========================== Column ========================== + const columnsWidth = React.useMemo<[key: React.Key, width: number, total: number][]>(() => { + let total = 0; + return flattenColumns.map(({ width, key }) => { + total += width as number; + return [key, width as number, total]; + }); + }, [flattenColumns]); + + const columnsOffset = React.useMemo( + () => columnsWidth.map(colWidth => colWidth[2]), + [columnsWidth], + ); + + React.useEffect(() => { + columnsWidth.forEach(([key, width]) => { + onColumnResize(key, width); + }); + }, [columnsWidth]); + + // =========================== Ref ============================ + React.useImperativeHandle(ref, () => { + const obj = {} as GridRef; + + Object.defineProperty(obj, 'scrollLeft', { + get: () => listRef.current?.getScrollInfo().x || 0, + + set: (value: number) => { + listRef.current?.scrollTo({ + left: value, + }); + }, + }); + + return obj; + }); + + // ======================= Col/Row Span ======================= + const getRowSpan = (column: ColumnType, index: number): number => { + const record = flattenData[index]?.record; + const { onCell } = column; + + if (onCell) { + const cellProps = onCell(record, index) as React.TdHTMLAttributes; + return cellProps?.rowSpan ?? 1; + } + return 1; + }; + + const extraRender: ListProps['extraRender'] = info => { + const { start, end, getSize, offsetY } = info; + + // Do nothing if no data + if (end < 0) { + return null; + } + + // Find first rowSpan column + let firstRowSpanColumns = flattenColumns.filter( + // rowSpan is 0 + column => getRowSpan(column, start) === 0, + ); + + let startIndex = start; + for (let i = start; i >= 0; i -= 1) { + firstRowSpanColumns = firstRowSpanColumns.filter(column => getRowSpan(column, i) === 0); + + if (!firstRowSpanColumns.length) { + startIndex = i; + break; + } + } + + // Find last rowSpan column + let lastRowSpanColumns = flattenColumns.filter( + // rowSpan is not 1 + column => getRowSpan(column, end) !== 1, + ); + + let endIndex = end; + for (let i = end; i < flattenData.length; i += 1) { + lastRowSpanColumns = lastRowSpanColumns.filter(column => getRowSpan(column, i) !== 1); + + if (!lastRowSpanColumns.length) { + endIndex = Math.max(i - 1, end); + break; + } + } + + // Collect the line who has rowSpan + const spanLines: number[] = []; + + for (let i = startIndex; i <= endIndex; i += 1) { + const item = flattenData[i]; + + // This code will never reach, just incase + if (!item) { + continue; + } + + if (flattenColumns.some(column => getRowSpan(column, i) > 1)) { + spanLines.push(i); + } + } + + // Patch extra line on the page + const nodes: React.ReactElement[] = spanLines.map(index => { + const item = flattenData[index]; + + const rowKey = getRowKey(item.record, index); + + const getHeight = (rowSpan: number) => { + const endItemIndex = index + rowSpan - 1; + const endItemKey = getRowKey(flattenData[endItemIndex].record, endItemIndex); + + const sizeInfo = getSize(rowKey, endItemKey); + return sizeInfo.bottom - sizeInfo.top; + }; + + const sizeInfo = getSize(rowKey); + + return ( + + ); + }); + + return nodes; + }; + + // ========================= Context ========================== + const gridContext = React.useMemo(() => ({ columnsOffset }), [columnsOffset]); + + // ========================== Render ========================== + const tblPrefixCls = `${prefixCls}-tbody`; + + let bodyContent: React.ReactNode; + if (flattenData.length) { + bodyContent = ( + > + ref={listRef} + className={classNames(tblPrefixCls, `${tblPrefixCls}-virtual`)} + height={scrollY} + itemHeight={listItemHeight || 24} + data={flattenData} + itemKey={item => getRowKey(item.record)} + scrollWidth={scrollX} + onVirtualScroll={({ x }) => { + onScroll({ + scrollLeft: x, + }); + }} + extraRender={extraRender} + > + {(item, index, itemProps) => { + const rowKey = getRowKey(item.record, index); + return ; + }} + + ); + } else { + bodyContent = ( +
+ + {emptyNode} + +
+ ); + } + + return {bodyContent}; +}); + +const ResponseGrid = responseImmutable(Grid); + +if (process.env.NODE_ENV !== 'production') { + ResponseGrid.displayName = 'ResponseGrid'; +} + +export default ResponseGrid; diff --git a/src/VirtualTable/BodyLine.tsx b/src/VirtualTable/BodyLine.tsx new file mode 100644 index 000000000..5d793059c --- /dev/null +++ b/src/VirtualTable/BodyLine.tsx @@ -0,0 +1,136 @@ +import { useContext } from '@rc-component/context'; +import classNames from 'classnames'; +import * as React from 'react'; +import TableContext, { responseImmutable } from '../context/TableContext'; +import type { FlattenData } from '../hooks/useFlattenRecords'; +import { StaticContext } from './context'; +import VirtualCell from './VirtualCell'; +import useRowInfo from '../hooks/useRowInfo'; +import Cell from '../Cell'; + +export interface BodyLineProps { + data: FlattenData; + index: number; + className?: string; + style?: React.CSSProperties; + rowKey: React.Key; + + /** Render cell only when it has `rowSpan > 1` */ + extra?: boolean; + getHeight?: (rowSpan: number) => number; +} + +const BodyLine = React.forwardRef((props, ref) => { + const { data, index, className, rowKey, style, extra, getHeight, ...restProps } = props; + const { record, indent } = data; + + const { flattenColumns, prefixCls, fixColumn, componentWidth } = useContext(TableContext, [ + 'prefixCls', + 'flattenColumns', + 'fixColumn', + 'componentWidth', + ]); + const { scrollX } = useContext(StaticContext, ['scrollX']); + + const rowInfo = useRowInfo(record, rowKey, index, indent); + + // ========================== Expand ========================== + const { rowSupportExpand, expanded, rowProps, expandedRowRender, expandedRowClassName } = rowInfo; + + let expandRowNode: React.ReactElement; + if (rowSupportExpand && expanded) { + const expandContent = expandedRowRender(record, index, indent + 1, expanded); + const computedExpandedRowClassName = expandedRowClassName?.(record, index, indent); + + let additionalProps: React.TdHTMLAttributes = {}; + if (fixColumn) { + additionalProps = { + style: { + ['--virtual-width' as any]: `${componentWidth}px`, + }, + }; + } + + const rowCellCls = `${prefixCls}-expanded-row-cell`; + + expandRowNode = ( +
+ + {expandContent} + +
+ ); + } + + // ========================== Render ========================== + + const rowStyle: React.CSSProperties = { + ...style, + width: scrollX, + }; + + if (extra) { + rowStyle.position = 'absolute'; + rowStyle.pointerEvents = 'none'; + } + + const rowNode = ( +
+ {flattenColumns.map((column, colIndex) => { + return ( + + ); + })} +
+ ); + + if (rowSupportExpand) { + return ( +
+ {rowNode} + {expandRowNode} +
+ ); + } + + return rowNode; +}); + +const ResponseBodyLine = responseImmutable(BodyLine); + +if (process.env.NODE_ENV !== 'production') { + ResponseBodyLine.displayName = 'BodyLine'; +} + +export default ResponseBodyLine; diff --git a/src/VirtualTable/VirtualCell.tsx b/src/VirtualTable/VirtualCell.tsx new file mode 100644 index 000000000..78e95c4b7 --- /dev/null +++ b/src/VirtualTable/VirtualCell.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import { getCellProps } from '../Body/BodyRow'; +import Cell from '../Cell'; +import type { ColumnType } from '../interface'; +import classNames from 'classnames'; +import { useContext } from '@rc-component/context'; +import { GridContext } from './context'; +import type useRowInfo from '../hooks/useRowInfo'; + +export interface VirtualCellProps { + rowInfo: ReturnType; + column: ColumnType; + colIndex: number; + indent: number; + index: number; + record: RecordType; + + // Follow props is used for RowSpanVirtualCell only + style?: React.CSSProperties; + className?: string; + + /** Render cell only when it has `rowSpan > 1` */ + inverse?: boolean; + getHeight?: (rowSpan: number) => number; +} + +/** + * Return the width of the column by `colSpan`. + * When `colSpan` is `0` will be trade as `1`. + */ +export function getColumnWidth(colIndex: number, colSpan: number, columnsOffset: number[]) { + const mergedColSpan = colSpan || 1; + return columnsOffset[colIndex + mergedColSpan] - (columnsOffset[colIndex] || 0); +} + +function VirtualCell( + props: VirtualCellProps, +) { + const { rowInfo, column, colIndex, indent, index, record, style, className, inverse, getHeight } = + props; + + const { render, dataIndex, className: columnClassName, width: colWidth } = column; + + const { columnsOffset } = useContext(GridContext, ['columnsOffset']); + + const { key, fixedInfo, appendCellNode, additionalCellProps } = getCellProps( + rowInfo, + column, + colIndex, + indent, + index, + ); + + const { style: cellStyle, colSpan = 1, rowSpan = 1 } = additionalCellProps; + + // ========================= ColWidth ========================= + // column width + const startColIndex = colIndex - 1; + const concatColWidth = getColumnWidth(startColIndex, colSpan, columnsOffset); + + // margin offset + const marginOffset = colSpan > 1 ? (colWidth as number) - concatColWidth : 0; + + // ========================== Style =========================== + const mergedStyle: React.CSSProperties = { + ...cellStyle, + ...style, + flex: `0 0 ${concatColWidth}px`, + width: `${concatColWidth}px`, + marginRight: marginOffset, + pointerEvents: 'auto', + }; + + // When `colSpan` or `rowSpan` is `0`, should skip render. + const needHide = React.useMemo(() => { + if (inverse) { + return rowSpan <= 1; + } else { + return colSpan === 0 || rowSpan === 0 || rowSpan > 1; + } + }, [rowSpan, colSpan, inverse]); + + // 0 rowSpan or colSpan should not render + if (needHide) { + mergedStyle.visibility = 'hidden'; + } else if (inverse) { + mergedStyle.height = getHeight?.(rowSpan); + } + const mergedRender = needHide ? () => null : render; + + // ========================== Render ========================== + const cellSpan: React.TdHTMLAttributes = {}; + + // Virtual should reset `colSpan` & `rowSpan` + if (rowSpan === 0 || colSpan === 0) { + cellSpan.rowSpan = 1; + cellSpan.colSpan = 1; + } + + return ( + + ); +} + +export default VirtualCell; diff --git a/src/VirtualTable/context.ts b/src/VirtualTable/context.ts new file mode 100644 index 000000000..d22fd0a98 --- /dev/null +++ b/src/VirtualTable/context.ts @@ -0,0 +1,15 @@ +import { createContext } from '@rc-component/context'; + +export interface StaticContextProps { + scrollX: number; + scrollY: number; + listItemHeight: number; +} + +export const StaticContext = createContext(null); + +export interface GridContextProps { + columnsOffset: number[]; +} + +export const GridContext = createContext(null); diff --git a/src/VirtualTable/index.tsx b/src/VirtualTable/index.tsx new file mode 100644 index 000000000..25935936e --- /dev/null +++ b/src/VirtualTable/index.tsx @@ -0,0 +1,84 @@ +import type { CompareProps } from '@rc-component/context/lib/Immutable'; +import * as React from 'react'; +import { INTERNAL_HOOKS } from '..'; +import type { CustomizeScrollBody } from '../interface'; +import Table, { DEFAULT_PREFIX, type TableProps } from '../Table'; +import Grid from './BodyGrid'; +import { StaticContext } from './context'; +import { makeImmutable } from '../context/TableContext'; +import { warning } from 'rc-util'; +import classNames from 'classnames'; + +const renderBody: CustomizeScrollBody = (rawData, props) => { + const { ref, onScroll } = props; + + return ; +}; + +export interface VirtualTableProps extends Omit, 'scroll'> { + scroll: { + x: number; + y: number; + }; + listItemHeight?: number; +} + +const PRESET_COLUMN_WIDTH = 100; + +function VirtualTable(props: VirtualTableProps) { + const { columns, scroll, prefixCls = DEFAULT_PREFIX, className, listItemHeight } = props; + + let { x: scrollX, y: scrollY } = scroll || {}; + + // Fill scrollX + if (typeof scrollX !== 'number') { + scrollX = ((columns || []).length + 1) * PRESET_COLUMN_WIDTH; + + if (process.env.NODE_ENV !== 'production') { + warning(false, '`scroll.x` in virtual table must be number.'); + } + } + + // Fill scrollY + if (typeof scrollY !== 'number') { + scrollY = 500; + + if (process.env.NODE_ENV !== 'production') { + warning(false, '`scroll.y` in virtual table must be number.'); + } + } + + // ========================= Context ========================== + const context = React.useMemo( + () => ({ scrollX, scrollY, listItemHeight }), + [scrollX, scrollY, listItemHeight], + ); + + // ========================== Render ========================== + return ( + + + + ); +} + +export function genVirtualTable( + shouldTriggerRender?: CompareProps, +): typeof VirtualTable { + return makeImmutable(VirtualTable, shouldTriggerRender); +} + +export default genVirtualTable(); diff --git a/src/context/TableContext.tsx b/src/context/TableContext.tsx index 5f9129036..625531ff9 100644 --- a/src/context/TableContext.tsx +++ b/src/context/TableContext.tsx @@ -1,4 +1,4 @@ -import { createContext } from '@rc-component/context'; +import { createContext, createImmutable } from '@rc-component/context'; import type { ColumnsType, ColumnType, @@ -6,6 +6,8 @@ import type { ExpandableType, ExpandedRowRender, GetComponent, + GetComponentProps, + GetRowKey, RenderExpandIcon, RowClassName, TableLayout, @@ -13,6 +15,9 @@ import type { } from '../interface'; import type { FixedInfo } from '../utils/fixUtil'; +const { makeImmutable, responseImmutable, useImmutableMark } = createImmutable(); +export { makeImmutable, responseImmutable, useImmutableMark }; + export interface TableContextProps { // Table prefixCls: string; @@ -30,6 +35,8 @@ export interface TableContextProps { // Body rowClassName: string | RowClassName; expandedRowClassName: RowClassName; + onRow?: GetComponentProps; + emptyNode?: React.ReactNode; tableLayout: TableLayout; @@ -51,6 +58,11 @@ export interface TableContextProps { hoverStartRow: number; hoverEndRow: number; onHover: (start: number, end: number) => void; + rowExpandable: (record: RecordType) => boolean; + + expandedKeys: Set; + getRowKey: GetRowKey; + childrenColumnName: string; } const TableContext = createContext(); diff --git a/src/hooks/useColumns.tsx b/src/hooks/useColumns/index.tsx similarity index 91% rename from src/hooks/useColumns.tsx rename to src/hooks/useColumns/index.tsx index ae1f0cee4..174486e1a 100644 --- a/src/hooks/useColumns.tsx +++ b/src/hooks/useColumns/index.tsx @@ -1,7 +1,7 @@ import toArray from 'rc-util/lib/Children/toArray'; import warning from 'rc-util/lib/warning'; import * as React from 'react'; -import { EXPAND_COLUMN } from '../constant'; +import { EXPAND_COLUMN } from '../../constant'; import type { ColumnGroupType, ColumnsType, @@ -12,8 +12,9 @@ import type { Key, RenderExpandIcon, TriggerEventHandler, -} from '../interface'; -import { INTERNAL_COL_DEFINE } from '../utils/legacyUtil'; +} from '../../interface'; +import { INTERNAL_COL_DEFINE } from '../../utils/legacyUtil'; +import useWidthColumns from './useWidthColumns'; export function convertChildrenToColumns( children: React.ReactNode, @@ -35,19 +36,23 @@ export function convertChildrenToColumns( }); } -function flatColumns(columns: ColumnsType): ColumnType[] { +function flatColumns( + columns: ColumnsType, + parentKey = 'key', +): ColumnType[] { return columns .filter(column => column && typeof column === 'object') - .reduce((list, column) => { + .reduce((list, column, index) => { const { fixed } = column; // Convert `fixed='true'` to `fixed='left'` instead const parsedFixed = fixed === true ? 'left' : fixed; + const mergedKey = `${parentKey}-${index}`; const subColumns = (column as ColumnGroupType).children; if (subColumns && subColumns.length > 0) { return [ ...list, - ...flatColumns(subColumns).map(subColum => ({ + ...flatColumns(subColumns, mergedKey).map(subColum => ({ fixed: parsedFixed, ...subColum, })), @@ -56,6 +61,7 @@ function flatColumns(columns: ColumnsType): ColumnType( expandRowByClick, columnWidth, fixed, + scrollWidth, }: { prefixCls?: string; columns?: ColumnsType; @@ -141,6 +148,7 @@ function useColumns( expandRowByClick?: boolean; columnWidth?: number | string; fixed?: FixedType; + scrollWidth?: number; }, transformColumns: (columns: ColumnsType) => ColumnsType, ): [ColumnsType, readonly ColumnType[]] { @@ -258,12 +266,17 @@ function useColumns( return revertForRtl(flatColumns(mergedColumns)); } return flatColumns(mergedColumns); - }, [mergedColumns, direction]); + }, [mergedColumns, direction, scrollWidth]); + // Only check out of production since it's waste for each render if (process.env.NODE_ENV !== 'production') { warningFixed(direction === 'rtl' ? flattenColumns.slice().reverse() : flattenColumns); } - return [mergedColumns, flattenColumns]; + + // ========================= FillWidth ======================== + const filledColumns = useWidthColumns(flattenColumns, scrollWidth); + + return [mergedColumns, filledColumns]; } export default useColumns; diff --git a/src/hooks/useColumns/useWidthColumns.tsx b/src/hooks/useColumns/useWidthColumns.tsx new file mode 100644 index 000000000..a85863643 --- /dev/null +++ b/src/hooks/useColumns/useWidthColumns.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import type { ColumnsType } from '../../interface'; + +function parseColWidth(totalWidth: number, width: string | number = '') { + if (typeof width === 'number') { + return width; + } + + if (width.endsWith('%')) { + return (totalWidth * parseFloat(width)) / 100; + } + return null; +} + +/** + * Fill all column with width + */ +export default function useWidthColumns(columns: ColumnsType, scrollWidth: number) { + const filledColumns = React.useMemo(() => { + // Fill width if needed + if (scrollWidth && scrollWidth > 0) { + let totalWidth = 0; + let missWidthCount = 0; + + // collect not given width column + columns.forEach((col: any) => { + const colWidth = parseColWidth(scrollWidth, col.width); + + if (colWidth) { + totalWidth += colWidth; + } else { + missWidthCount += 1; + } + }); + + // Fill width + let restWidth = scrollWidth - totalWidth; + let restCount = missWidthCount; + const avgWidth = restWidth / missWidthCount; + + return columns.map((col: any) => { + const clone = { + ...col, + }; + + const colWidth = parseColWidth(scrollWidth, clone.width); + + if (colWidth) { + clone.width = colWidth; + } else { + const colAvgWidth = Math.floor(avgWidth); + + clone.width = restCount === 1 ? restWidth : colAvgWidth; + + restWidth -= colAvgWidth; + restCount -= 1; + } + + return clone; + }); + } + + return columns; + }, [columns, scrollWidth]); + + return filledColumns; +} diff --git a/src/hooks/useFixedInfo.ts b/src/hooks/useFixedInfo.ts index 9f05c8711..bafef0981 100644 --- a/src/hooks/useFixedInfo.ts +++ b/src/hooks/useFixedInfo.ts @@ -7,10 +7,17 @@ export default function useFixedInfo( flattenColumns: readonly ColumnType[], stickyOffsets: StickyOffsets, direction: Direction, - columns: ColumnsType + columns: ColumnsType, ) { const fixedInfoList = flattenColumns.map((_, colIndex) => - getCellFixedInfo(colIndex, colIndex, flattenColumns, stickyOffsets, direction, columns?.[colIndex]), + getCellFixedInfo( + colIndex, + colIndex, + flattenColumns, + stickyOffsets, + direction, + columns?.[colIndex], + ), ); return useMemo( diff --git a/src/hooks/useFlattenRecords.ts b/src/hooks/useFlattenRecords.ts index 3ec8bab36..ff67f5d9d 100644 --- a/src/hooks/useFlattenRecords.ts +++ b/src/hooks/useFlattenRecords.ts @@ -2,7 +2,8 @@ import * as React from 'react'; import type { GetRowKey, Key } from '../interface'; // recursion (flat tree structure) -function flatRecord( +function fillRecords( + list: FlattenData[], record: T, indent: number, childrenColumnName: string, @@ -10,9 +11,7 @@ function flatRecord( getRowKey: GetRowKey, index: number, ) { - const arr = []; - - arr.push({ + list.push({ record, indent, index, @@ -25,7 +24,8 @@ function flatRecord( if (record && Array.isArray(record[childrenColumnName]) && expanded) { // expanded state, flat record for (let i = 0; i < record[childrenColumnName].length; i += 1) { - const tempArr = flatRecord( + fillRecords( + list, record[childrenColumnName][i], indent + 1, childrenColumnName, @@ -33,12 +33,14 @@ function flatRecord( getRowKey, i, ); - - arr.push(...tempArr); } } +} - return arr; +export interface FlattenData { + record: RecordType; + indent: number; + index: number; } /** @@ -53,26 +55,24 @@ function flatRecord( * @returns flattened data */ export default function useFlattenRecords( - data, + data: T[] | readonly T[], childrenColumnName: string, expandedKeys: Set, getRowKey: GetRowKey, -) { - const arr: { record: T; indent: number; index: number }[] = React.useMemo(() => { +): FlattenData[] { + const arr: FlattenData[] = React.useMemo(() => { if (expandedKeys?.size) { - let temp: { record: T; indent: number; index: number }[] = []; + const list: FlattenData[] = []; // collect flattened record for (let i = 0; i < data?.length; i += 1) { const record = data[i]; // using array.push or spread operator may cause "Maximum call stack size exceeded" exception if array size is big enough. - temp = temp.concat( - ...flatRecord(record, 0, childrenColumnName, expandedKeys, getRowKey, i), - ); + fillRecords(list, record, 0, childrenColumnName, expandedKeys, getRowKey, i); } - return temp; + return list; } return data?.map((item, index) => { diff --git a/src/hooks/useRowInfo.tsx b/src/hooks/useRowInfo.tsx new file mode 100644 index 000000000..bba123a89 --- /dev/null +++ b/src/hooks/useRowInfo.tsx @@ -0,0 +1,123 @@ +import { useContext } from '@rc-component/context'; +import type { TableContextProps } from '../context/TableContext'; +import TableContext from '../context/TableContext'; +import { getColumnsKey } from '../utils/valueUtil'; +import { useEvent } from 'rc-util'; +import classNames from 'classnames'; + +export default function useRowInfo( + record: RecordType, + rowKey: React.Key, + recordIndex: number, + indent: number, +): Pick< + TableContextProps, + | 'prefixCls' + | 'fixedInfoList' + | 'flattenColumns' + | 'expandableType' + | 'expandRowByClick' + | 'onTriggerExpand' + | 'rowClassName' + | 'expandedRowClassName' + | 'indentSize' + | 'expandIcon' + | 'expandedRowRender' + | 'expandIconColumnIndex' + | 'expandedKeys' + | 'childrenColumnName' + | 'onRow' +> & { + columnsKey: React.Key[]; + nestExpandable: boolean; + expanded: boolean; + hasNestChildren: boolean; + record: RecordType; + rowSupportExpand: boolean; + expandable: boolean; + rowProps: React.HTMLAttributes & React.TdHTMLAttributes; +} { + const context: TableContextProps = useContext(TableContext, [ + 'prefixCls', + 'fixedInfoList', + 'flattenColumns', + 'expandableType', + 'expandRowByClick', + 'onTriggerExpand', + 'rowClassName', + 'expandedRowClassName', + 'indentSize', + 'expandIcon', + 'expandedRowRender', + 'expandIconColumnIndex', + 'expandedKeys', + 'childrenColumnName', + 'rowExpandable', + 'onRow', + ]); + + const { + flattenColumns, + expandableType, + expandedKeys, + childrenColumnName, + onTriggerExpand, + rowExpandable, + onRow, + expandRowByClick, + rowClassName, + } = context; + + // ======================= Expandable ======================= + // Only when row is not expandable and `children` exist in record + const nestExpandable = expandableType === 'nest'; + + const rowSupportExpand = expandableType === 'row' && (!rowExpandable || rowExpandable(record)); + const mergedExpandable = rowSupportExpand || nestExpandable; + + const expanded = expandedKeys && expandedKeys.has(rowKey); + + const hasNestChildren = childrenColumnName && record && record[childrenColumnName]; + + const onInternalTriggerExpand = useEvent(onTriggerExpand); + + // ========================= onRow ========================== + const rowProps = onRow?.(record, recordIndex); + const onRowClick = rowProps?.onClick; + + const onClick: React.MouseEventHandler = (event, ...args) => { + if (expandRowByClick && mergedExpandable) { + onTriggerExpand(record, event); + } + + onRowClick?.(event, ...args); + }; + + // ====================== RowClassName ====================== + let computeRowClassName: string; + if (typeof rowClassName === 'string') { + computeRowClassName = rowClassName; + } else if (typeof rowClassName === 'function') { + computeRowClassName = rowClassName(record, recordIndex, indent); + } + + // ========================= Column ========================= + const columnsKey = getColumnsKey(flattenColumns); + + return { + ...context, + columnsKey, + nestExpandable, + expanded, + hasNestChildren, + record, + onTriggerExpand: onInternalTriggerExpand, + rowSupportExpand, + expandable: mergedExpandable, + rowProps: { + ...rowProps, + className: classNames(computeRowClassName, rowProps?.className), + onClick, + }, + }; +} diff --git a/src/index.ts b/src/index.ts index 935ebba1e..81dc8b5a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import { EXPAND_COLUMN, INTERNAL_HOOKS } from './constant'; import { FooterComponents as Summary } from './Footer'; +import VirtualTable, { genVirtualTable } from './VirtualTable'; +import type { VirtualTableProps } from './VirtualTable'; import Column from './sugar/Column'; import ColumnGroup from './sugar/ColumnGroup'; import type { TableProps } from './Table'; @@ -15,6 +17,9 @@ export { INTERNAL_COL_DEFINE, EXPAND_COLUMN, INTERNAL_HOOKS, + VirtualTable, + genVirtualTable, + type VirtualTableProps, }; export default Table; diff --git a/src/interface.ts b/src/interface.ts index a2d13c413..b9eb10433 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -116,7 +116,7 @@ export interface StickyOffsets { export type GetComponentProps = ( data: DataType, index?: number, -) => React.HTMLAttributes | React.TdHTMLAttributes; +) => React.HTMLAttributes & React.TdHTMLAttributes; type Component

= | React.ComponentType

@@ -126,12 +126,17 @@ type Component

= export type CustomizeComponent = Component; +export type OnCustomizeScroll = (info: { + currentTarget?: HTMLElement; + scrollLeft?: number; +}) => void; + export type CustomizeScrollBody = ( data: readonly RecordType[], info: { scrollbarSize: number; ref: React.Ref<{ scrollLeft: number }>; - onScroll: (info: { currentTarget?: HTMLElement; scrollLeft?: number }) => void; + onScroll: OnCustomizeScroll; }, ) => React.ReactNode; diff --git a/tests/FixedHeader.spec.jsx b/tests/FixedHeader.spec.jsx index adb4ed885..c3d3dd8f4 100644 --- a/tests/FixedHeader.spec.jsx +++ b/tests/FixedHeader.spec.jsx @@ -36,25 +36,27 @@ describe('Table.FixedHeader', () => { scroll={{ y: 10 }} />, ); - wrapper - .find(RcResizeObserver.Collection) - .first() - .props() - .onBatchResize([ - { - data: wrapper.find('ResizeObserver').at(0).props().data, - size: { width: 100, offsetWidth: 100 }, - }, - { - data: wrapper.find('ResizeObserver').at(1).props().data, - size: { width: 200, offsetWidth: 200 }, - }, - { - data: wrapper.find('ResizeObserver').at(2).props().data, - size: { width: 0, offsetWidth: 0 }, - }, - ]); - await safeAct(wrapper); + + async function triggerResize(resizeList) { + wrapper.find(RcResizeObserver.Collection).first().props().onBatchResize(resizeList); + await safeAct(wrapper); + wrapper.update(); + } + + await triggerResize([ + { + data: wrapper.find('ResizeObserver').at(0).props().data, + size: { width: 100, offsetWidth: 100 }, + }, + { + data: wrapper.find('ResizeObserver').at(1).props().data, + size: { width: 200, offsetWidth: 200 }, + }, + { + data: wrapper.find('ResizeObserver').at(2).props().data, + size: { width: 0, offsetWidth: 0 }, + }, + ]); expect(wrapper.find('.rc-table-header table').props().style.visibility).toBeFalsy(); @@ -64,7 +66,17 @@ describe('Table.FixedHeader', () => { // Update columns wrapper.setProps({ columns: [col2, col1] }); - wrapper.update(); + + await triggerResize([ + { + data: wrapper.find('ResizeObserver').at(0).props().data, + size: { width: 200, offsetWidth: 200 }, + }, + { + data: wrapper.find('ResizeObserver').at(1).props().data, + size: { width: 100, offsetWidth: 100 }, + }, + ]); expect(wrapper.find('colgroup col').at(0).props().style.width).toEqual(200); expect(wrapper.find('colgroup col').at(1).props().style.width).toEqual(100); diff --git a/tests/Table.spec.jsx b/tests/Table.spec.jsx index 39ecc5e1b..1c62fe31f 100644 --- a/tests/Table.spec.jsx +++ b/tests/Table.spec.jsx @@ -1,11 +1,11 @@ import { mount } from 'enzyme'; import { resetWarned } from 'rc-util/lib/warning'; import React from 'react'; +import { VariableSizeGrid as Grid } from 'react-window'; import Table, { INTERNAL_COL_DEFINE } from '../src'; import BodyRow from '../src/Body/BodyRow'; import Cell from '../src/Cell'; import { INTERNAL_HOOKS } from '../src/constant'; -import { VariableSizeGrid as Grid } from "react-window"; describe('Table.Basic', () => { const data = [ @@ -684,22 +684,6 @@ describe('Table.Basic', () => { ); errSpy.mockRestore(); }); - - it('without scroll', () => { - resetWarned(); - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mount( - createTable({ - components: { - body: () =>

Bamboo

, - }, - }), - ); - expect(errSpy).toHaveBeenCalledWith( - 'Warning: `components.body` with render props is only work on `scroll.y`.', - ); - errSpy.mockRestore(); - }); }); it('without warning - columns is empty', () => { @@ -1222,52 +1206,54 @@ describe('Table.Basic', () => { const width = 150; const noChildColLen = 4; const ChildColLen = 4; - const buildChildDataIndex = (n) => `col${n}`; + const buildChildDataIndex = n => `col${n}`; const columns = Array.from({ length: noChildColLen }, (_, i) => ({ title: `第 ${i} 列`, dataIndex: buildChildDataIndex(i), width, - })).concat(Array.from({ length: ChildColLen }, (_, i) => ({ - title: `第 ${i} 分组`, - dataIndex: `parentCol${i}`, - width: width * 2, - children: [ - { - title: `第 ${noChildColLen + i} 列`, - dataIndex: buildChildDataIndex(noChildColLen + i), - width, - }, - { - title: `第 ${noChildColLen + 1 + i} 列`, - dataIndex: buildChildDataIndex(noChildColLen + 1 + i), - width, - }, - ] - }))); + })).concat( + Array.from({ length: ChildColLen }, (_, i) => ({ + title: `第 ${i} 分组`, + dataIndex: `parentCol${i}`, + width: width * 2, + children: [ + { + title: `第 ${noChildColLen + i} 列`, + dataIndex: buildChildDataIndex(noChildColLen + i), + width, + }, + { + title: `第 ${noChildColLen + 1 + i} 列`, + dataIndex: buildChildDataIndex(noChildColLen + 1 + i), + width, + }, + ], + })), + ); const data = Array.from({ length: 10000 }, (_, r) => { const colLen = noChildColLen + ChildColLen * 2; const record = {}; - for (let c = 0; c < colLen; c ++) { - record[buildChildDataIndex(c)] = `r${r}, c${c}` + for (let c = 0; c < colLen; c++) { + record[buildChildDataIndex(c)] = `r${r}, c${c}`; } return record; - }) - const Demo = (props) => { + }); + const Demo = props => { const gridRef = React.useRef(); const [connectObject] = React.useState(() => { const obj = {}; - Object.defineProperty(obj, "scrollLeft", { + Object.defineProperty(obj, 'scrollLeft', { get: () => { if (gridRef.current) { return gridRef.current?.state?.scrollLeft; } return null; }, - set: (scrollLeft) => { + set: scrollLeft => { if (gridRef.current) { gridRef.current.scrollTo({ scrollLeft }); } - } + }, }); return obj; @@ -1276,7 +1262,7 @@ describe('Table.Basic', () => { React.useEffect(() => { gridRef.current.resetAfterIndices({ columnIndex: 0, - shouldForceUpdate: false + shouldForceUpdate: false, }); }, []); @@ -1287,11 +1273,9 @@ describe('Table.Basic', () => { ref={gridRef} className="virtual-grid" columnCount={columns.length} - columnWidth={(index) => { + columnWidth={index => { const { width } = columns[index]; - return index === columns.length - 1 - ? width - scrollbarSize - 1 - : width; + return index === columns.length - 1 ? width - scrollbarSize - 1 : width; }} height={300} rowCount={rawData.length} @@ -1303,7 +1287,9 @@ describe('Table.Basic', () => { > {({ columnIndex, rowIndex, style }) => (
r{rowIndex}, c{columnIndex} @@ -1321,12 +1307,17 @@ describe('Table.Basic', () => { data={props.data} scroll={{ y: 300, x: 300 }} components={{ - body: renderVirtualList + body: renderVirtualList, }} /> ); }; const wrapper = mount(); - expect(wrapper.find('col').at(noChildColLen + ChildColLen * 2 - 1).props().style.width + wrapper.find('col').last().props().style.width).toEqual(width); - }) + expect( + wrapper + .find('col') + .at(noChildColLen + ChildColLen * 2 - 1) + .props().style.width + wrapper.find('col').last().props().style.width, + ).toEqual(width); + }); }); diff --git a/tests/Virtual.spec.tsx b/tests/Virtual.spec.tsx new file mode 100644 index 000000000..fedd372ed --- /dev/null +++ b/tests/Virtual.spec.tsx @@ -0,0 +1,183 @@ +import { resetWarned } from 'rc-util/lib/warning'; +import React from 'react'; +import { type VirtualTableProps, VirtualTable } from '../src'; +import { act, fireEvent, render } from '@testing-library/react'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import { _rs as onEsResize } from 'rc-resize-observer/es/utils/observerUtil'; +import { _rs as onLibResize } from 'rc-resize-observer/lib/utils/observerUtil'; + +describe('Table.Virtual', () => { + let scrollLeftCalled = false; + + beforeAll(() => { + spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ + width: 50, + }), + scrollLeft: { + get: () => { + scrollLeftCalled = true; + return 100; + }, + set: () => {}, + }, + }); + }); + + beforeEach(() => { + scrollLeftCalled = false; + vi.useFakeTimers(); + resetWarned(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + async function waitFakeTimer() { + for (let i = 0; i < 10; i += 1) { + // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-loop-func + await act(async () => { + vi.advanceTimersByTime(100); + await Promise.resolve(); + }); + } + } + + function resize(target: HTMLElement) { + act(() => { + onLibResize([{ target } as any]); + onEsResize([{ target } as any]); + }); + } + + function getTable(props?: Partial>) { + return render( + ({ + name: `name${index}`, + age: index, + address: `address${index}`, + }))} + {...props} + />, + ); + } + + it('should work', async () => { + const { container } = getTable(); + + await waitFakeTimer(); + + expect(container.querySelector('.rc-virtual-list')).toBeTruthy(); + }); + + it('warning for scroll props is not a number', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + getTable({ + scroll: {} as any, + }); + + expect(errSpy).toHaveBeenCalledWith('Warning: `scroll.x` in virtual table must be number.'); + expect(errSpy).toHaveBeenCalledWith('Warning: `scroll.y` in virtual table must be number.'); + }); + + it('rowSpan', () => { + const { container } = getTable({ + columns: [ + { + dataIndex: 'name', + onCell: (_, index) => ({ + rowSpan: index % 2 ? 0 : 2, + }), + }, + ], + }); + + expect(container.querySelector('.rc-table-row-extra').textContent).toBe('name0'); + }); + + it('empty', () => { + const { container } = getTable({ + data: [], + }); + + expect(container.querySelector('.rc-table-placeholder').textContent).toBe('No Data'); + }); + + describe('expandable', () => { + it('basic', () => { + const { container } = getTable({ + expandable: { + expandedRowKeys: ['name0', 'name3'], + expandedRowRender: record => record.name, + }, + }); + + const expandedCells = container.querySelectorAll('.rc-table-expanded-row-cell'); + expect(expandedCells).toHaveLength(2); + expect(expandedCells[0].textContent).toBe('name0'); + expect(expandedCells[1].textContent).toBe('name3'); + }); + + it('fixed', () => { + const { container } = getTable({ + columns: [ + { + dataIndex: 'name', + fixed: 'left', + }, + { + dataIndex: 'age', + }, + { + dataIndex: 'address', + }, + ], + expandable: { + expandedRowKeys: ['name0', 'name3'], + expandedRowRender: record => record.name, + }, + }); + + const expandedCells = container.querySelectorAll('.rc-table-expanded-row-cell-fixed'); + expect(expandedCells).toHaveLength(2); + expect(expandedCells[0].textContent).toBe('name0'); + expect(expandedCells[1].textContent).toBe('name3'); + }); + }); + + it('scroll sync', () => { + const { container } = getTable(); + + resize(container.querySelector('.rc-table')!); + + scrollLeftCalled = false; + expect(scrollLeftCalled).toBeFalsy(); + console.log('!!!!!'); + + // fireEvent.scroll(container.querySelector('.rc-table-header')!); + fireEvent.wheel(container.querySelector('.rc-virtual-list-holder')!, { + deltaX: 10, + }); + expect(scrollLeftCalled).toBeTruthy(); + + console.log(container.innerHTML); + }); +});