From 17b96c9027a0e88386087ce4538a2e87f23fc455 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 24 Sep 2024 02:05:42 +0800 Subject: [PATCH] feat(data-table): supports horizontal virtual scrolling --- package.json | 2 +- src/data-table/demos/enUS/index.demo-entry.md | 1 + src/data-table/demos/enUS/virtual-x.demo.vue | 82 +++ src/data-table/demos/zhCN/index.demo-entry.md | 1 + src/data-table/demos/zhCN/virtual-x.demo.vue | 82 +++ src/data-table/src/DataTable.tsx | 5 + src/data-table/src/MainTable.tsx | 8 +- src/data-table/src/TableParts/Body.tsx | 555 +++++++++++------- src/data-table/src/TableParts/Header.tsx | 404 ++++++++----- src/data-table/src/interface.ts | 17 + src/data-table/src/styles/index.cssr.ts | 1 + src/data-table/src/use-group-header.ts | 17 +- 12 files changed, 821 insertions(+), 354 deletions(-) create mode 100644 src/data-table/demos/enUS/virtual-x.demo.vue create mode 100644 src/data-table/demos/zhCN/virtual-x.demo.vue diff --git a/package.json b/package.json index 4011e39217d..d98e8755db8 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", - "vueuc": "^0.4.58" + "vueuc": "^0.4.63" }, "devDependencies": { "@antfu/eslint-config": "^2.22.0", diff --git a/src/data-table/demos/enUS/index.demo-entry.md b/src/data-table/demos/enUS/index.demo-entry.md index d2355beaf3a..4f108e1f62d 100644 --- a/src/data-table/demos/enUS/index.demo-entry.md +++ b/src/data-table/demos/enUS/index.demo-entry.md @@ -49,6 +49,7 @@ render-header custom-style.vue ajax-usage virtual.vue +virtual-x.vue custom-filter-menu.vue tree.vue flex-height.vue diff --git a/src/data-table/demos/enUS/virtual-x.demo.vue b/src/data-table/demos/enUS/virtual-x.demo.vue new file mode 100644 index 00000000000..37bc34e4e5b --- /dev/null +++ b/src/data-table/demos/enUS/virtual-x.demo.vue @@ -0,0 +1,82 @@ + +# Large data (rows & cols) + +If you have a large amount of row and column data, such as thousands of rows and hundreds of columns, `naive-ui` provides horizontal + vertical virtual scrolling functionality. + +Due to the inherent complexity of horizontal virtual scrolling, the corresponding configuration can be quite complex, with most of the following content being necessary: + +1. Configure `virtual-scroll` to enable vertical virtual scrolling. +2. Configure `virtual-scroll-x` to enable horizontal virtual scrolling: + - Each column needs to have a `width` property configured. + - Configure the `scroll-x` property, setting it to the total width of all columns. + - Configure the `min-row-height` property, setting it to the minimum height of each row, where all rows must be larger than this value. + - Configure the `height-for-row` property, which is used to set the height of each row (since only a portion of the cells in each row are always visible, this cannot be automatically calculated). If not configured, the height of each row will be set to `min-row-height`. +3. If needed, configure `virtual-scroll-header`. By default, the header will still be fully rendered to maintain compatibility. You can enable virtual rendering for the header with this configuration: + - Configure the `header-height` property, setting it to the height of the header. + +The example below corresponds to a table with 1000 rows * 1000 columns. + +`naive-ui`'s table can easily support table data in the millions. You won't find this kind of functionality in many free component libraries. + + + + + diff --git a/src/data-table/demos/zhCN/index.demo-entry.md b/src/data-table/demos/zhCN/index.demo-entry.md index df68978d47b..86108b145a2 100644 --- a/src/data-table/demos/zhCN/index.demo-entry.md +++ b/src/data-table/demos/zhCN/index.demo-entry.md @@ -51,6 +51,7 @@ render-header custom-style.vue ajax-usage virtual.vue +virtual-x.vue custom-filter-menu.vue tree.vue flex-height.vue diff --git a/src/data-table/demos/zhCN/virtual-x.demo.vue b/src/data-table/demos/zhCN/virtual-x.demo.vue new file mode 100644 index 00000000000..392f6c5e544 --- /dev/null +++ b/src/data-table/demos/zhCN/virtual-x.demo.vue @@ -0,0 +1,82 @@ + +# 大量数据(行和列) + +如果你有大量行数据和列数据,例如几千行 + 几百列,`naive-ui` 提供了横向 + 纵向虚拟滚动的功能。 + +因为横向虚拟滚动的天然的复杂性,对应的配置也会较为复杂,以下多数内容都是必须的: + +1. 配置 `virtual-scroll` 打开纵向虚拟滚动 +2. 配置 `virtual-scroll-x` 打开横向虚拟滚动 + - 每一个列都需要配置 `width` 属性 + - 配置 `scroll-x` 属性,设为所有列的总宽度 + - 配置 `min-row-height` 属性,设为每一列的最小高度,所有的列必须比这个值更大 + - 配置 `height-for-row` 属性,用于配置每一行的高度(因为每一行永远只有一部分格子是可见的,因此无法自动求出),如果不配置,每一行的高度会被设为 `min-row-height` +3. 如有需要,配置 `virtual-scroll-header`,默认情况下,表头依然会全量渲染以保持兼容性,你可以通过此配置来打开表头的虚拟渲染 + - 配置 `header-height` 属性,设为表头的高度 + +下面的例子对应了一个 1000 行 * 1000 列的表格。 + +`naive-ui` 的表格可以轻松的支持千万级的表格数据,你在不收钱的组件库不容易找得到这样的功能。 + + + + + diff --git a/src/data-table/src/DataTable.tsx b/src/data-table/src/DataTable.tsx index fd9437e0081..4895b6fd080 100644 --- a/src/data-table/src/DataTable.tsx +++ b/src/data-table/src/DataTable.tsx @@ -244,6 +244,11 @@ export default defineComponent({ renderExpandRef, summaryRef: toRef(props, 'summary'), virtualScrollRef: toRef(props, 'virtualScroll'), + virtualScrollXRef: toRef(props, 'virtualScrollX'), + heightForRowRef: toRef(props, 'heightForRow'), + minRowHeightRef: toRef(props, 'minRowHeight'), + virtualScrollHeaderRef: toRef(props, 'virtualScrollHeader'), + headerHeightRef: toRef(props, 'headerHeight'), rowPropsRef: toRef(props, 'rowProps'), stripedRef: toRef(props, 'striped'), checkOptionsRef: computed(() => { diff --git a/src/data-table/src/MainTable.tsx b/src/data-table/src/MainTable.tsx index 1031c893a70..cbccf6f461e 100644 --- a/src/data-table/src/MainTable.tsx +++ b/src/data-table/src/MainTable.tsx @@ -20,6 +20,7 @@ export default defineComponent({ maxHeightRef, minHeightRef, flexHeightRef, + virtualScrollHeaderRef, syncScrollState } = inject(dataTableInjectionKey)! @@ -47,7 +48,12 @@ export default defineComponent({ function getHeaderElement(): HTMLElement | null { const { value } = headerInstRef if (value) { - return value.$el + if (virtualScrollHeaderRef.value) { + return value.virtualListRef?.listElRef || null + } + else { + return value.$el + } } return null } diff --git a/src/data-table/src/TableParts/Body.tsx b/src/data-table/src/TableParts/Body.tsx index 295c6e437f4..6b9ed62579c 100644 --- a/src/data-table/src/TableParts/Body.tsx +++ b/src/data-table/src/TableParts/Body.tsx @@ -1,8 +1,6 @@ +import type { CSSProperties, PropType, VNode, VNodeChild } from 'vue' import { - type CSSProperties, Fragment, - type PropType, - type VNode, computed, defineComponent, h, @@ -12,7 +10,8 @@ import { watchEffect } from 'vue' import { pxfy, repeat } from 'seemly' -import { VResizeObserver, VirtualList, type VirtualListInst } from 'vueuc' +import { VResizeObserver, VirtualList } from 'vueuc' +import type { VirtualListInst } from 'vueuc' import type { CNode } from 'css-render' import { useMemo } from 'vooks' import { cssrAnchorMetaName } from '../../../_mixins/common' @@ -170,6 +169,9 @@ export default defineComponent({ summaryRef, mergedSortStateRef, virtualScrollRef, + virtualScrollXRef, + heightForRowRef, + minRowHeightRef, componentId, mergedTableLayoutRef, childTriggerColIndexRef, @@ -499,6 +501,9 @@ export default defineComponent({ hoverKey: hoverKeyRef, mergedSortState: mergedSortStateRef, virtualScroll: virtualScrollRef, + virtualScrollX: virtualScrollXRef, + heightForRow: heightForRowRef, + minRowHeight: minRowHeightRef, mergedTableLayout: mergedTableLayoutRef, childTriggerColIndex: childTriggerColIndexRef, indent: indentRef, @@ -597,7 +602,10 @@ export default defineComponent({ summary, handleCheckboxUpdateChecked, handleRadioUpdateChecked, - handleUpdateExpanded + handleUpdateExpanded, + heightForRow, + minRowHeight, + virtualScrollX } = this const { length: colCount } = cols @@ -683,11 +691,40 @@ export default defineComponent({ const bodyWidthPx = bodyWidth === null ? undefined : `${bodyWidth}px` - const renderRow = ( - rowInfo: RowRenderInfo, - displayedRowIndex: number, + const CellComponent = (this.virtualScrollX ? 'div' : 'td') as 'td' + let leftFixedColsCount = 0 + let rightFixedColsCount = 0 + if (virtualScrollX) { + cols.forEach((col) => { + if (col.column.fixed === 'left') { + leftFixedColsCount++ + } + else if (col.column.fixed === 'right') { + rightFixedColsCount++ + } + }) + } + + const renderRow = ({ + // Normal + rowInfo, + displayedRowIndex, + isVirtual, + // Virtual X + isVirtualX, + startColIndex, + endColIndex, + getLeft + }: { + rowInfo: RowRenderInfo + displayedRowIndex: number isVirtual: boolean - ): VNode => { + // for horizontal virtual list + isVirtualX: boolean + startColIndex: number + endColIndex: number + getLeft: (index: number) => number + }): VNode => { const { index: actualRowIndex } = rowInfo if ('isExpandedRow' in rowInfo) { const { @@ -735,6 +772,244 @@ export default defineComponent({ = typeof rowClassName === 'string' ? rowClassName : createRowClassName(rowData, actualRowIndex, rowClassName) + const iteratedCols = isVirtualX + ? cols.filter((col, index) => { + if (startColIndex <= index && index <= endColIndex) + return true + if (col.column.fixed) { + return true + } + return false + }) + : cols + const virtualXRowHeight = isVirtualX + ? pxfy( + heightForRow?.(rowData, actualRowIndex) + || minRowHeight + || 28 + ) + : undefined + const cells = iteratedCols.map((col) => { + const colIndex = col.index + if (displayedRowIndex in cordToPass) { + const cordOfRowToPass = cordToPass[displayedRowIndex] + const indexInCordOfRowToPass + = cordOfRowToPass.indexOf(colIndex) + if (~indexInCordOfRowToPass) { + cordOfRowToPass.splice(indexInCordOfRowToPass, 1) + return null + } + } + // TODO: Simplify row calculation + const { column } = col + const colKey = getColKey(col) + const { rowSpan, colSpan } = column + const mergedColSpan = isSummary + ? rowInfo.tmNode.rawNode[colKey]?.colSpan || 1 // optional for #1276 + : colSpan + ? colSpan(rowData, actualRowIndex) + : 1 + const mergedRowSpan = isSummary + ? rowInfo.tmNode.rawNode[colKey]?.rowSpan || 1 // optional for #1276 + : rowSpan + ? rowSpan(rowData, actualRowIndex) + : 1 + const isLastCol = colIndex + mergedColSpan === colCount + const isLastRow = displayedRowIndex + mergedRowSpan === rowCount + const isCrossRowTd = mergedRowSpan > 1 + if (isCrossRowTd) { + cordKey[displayedRowIndex] = { + [colIndex]: [] + } + } + if (mergedColSpan > 1 || isCrossRowTd) { + for ( + let i = displayedRowIndex; + i < displayedRowIndex + mergedRowSpan; + ++i + ) { + if (isCrossRowTd) { + cordKey[displayedRowIndex][colIndex].push( + rowIndexToKey[i] + ) + } + for (let j = colIndex; j < colIndex + mergedColSpan; ++j) { + if (i === displayedRowIndex && j === colIndex) { + continue + } + if (!(i in cordToPass)) { + cordToPass[i] = [j] + } + else { + cordToPass[i].push(j) + } + } + } + } + const hoverKey = isCrossRowTd ? this.hoverKey : null + const { cellProps } = column + const resolvedCellProps = cellProps?.(rowData, actualRowIndex) + const indentOffsetStyle = { + '--indent-offset': '' as string | number + } + const FinalCellComponent = column.fixed ? 'td' : CellComponent + return ( + + {hasChildren && colIndex === childTriggerColIndex + ? [ + repeat( + (indentOffsetStyle['--indent-offset'] = isSummary + ? 0 + : rowInfo.tmNode.level), +
+ ), + isSummary || rowInfo.tmNode.isLeaf ? ( +
+ ) : ( + { + handleUpdateExpanded(rowKey, rowInfo.tmNode) + }} + /> + ) + ] + : null} + {column.type === 'selection' ? ( + !isSummary ? ( + column.multiple === false ? ( + { + handleRadioUpdateChecked(rowInfo.tmNode) + }} + /> + ) : ( + { + handleCheckboxUpdateChecked( + rowInfo.tmNode, + checked, + e.shiftKey + ) + }} + /> + ) + ) : null + ) : column.type === 'expand' ? ( + !isSummary ? ( + !column.expandable || column.expandable?.(rowData) ? ( + { + handleUpdateExpanded(rowKey, null) + }} + /> + ) : null + ) : null + ) : ( + + )} + + ) + }) + + if (isVirtualX) { + if (leftFixedColsCount && rightFixedColsCount) { + cells.splice( + leftFixedColsCount, + 0, + + ) + } + } + const row = ( { @@ -746,211 +1021,15 @@ export default defineComponent({ isSummary && `${mergedClsPrefix}-data-table-tr--summary`, striped && `${mergedClsPrefix}-data-table-tr--striped`, expanded && `${mergedClsPrefix}-data-table-tr--expanded`, - mergedRowClassName + mergedRowClassName, + props?.class ]} + style={props?.style} {...props} > - {cols.map((col, colIndex) => { - if (displayedRowIndex in cordToPass) { - const cordOfRowToPass = cordToPass[displayedRowIndex] - const indexInCordOfRowToPass - = cordOfRowToPass.indexOf(colIndex) - if (~indexInCordOfRowToPass) { - cordOfRowToPass.splice(indexInCordOfRowToPass, 1) - return null - } - } - - // TODO: Simplify row calculation - const { column } = col - const colKey = getColKey(col) - const { rowSpan, colSpan } = column - const mergedColSpan = isSummary - ? rowInfo.tmNode.rawNode[colKey]?.colSpan || 1 // optional for #1276 - : colSpan - ? colSpan(rowData, actualRowIndex) - : 1 - const mergedRowSpan = isSummary - ? rowInfo.tmNode.rawNode[colKey]?.rowSpan || 1 // optional for #1276 - : rowSpan - ? rowSpan(rowData, actualRowIndex) - : 1 - const isLastCol = colIndex + mergedColSpan === colCount - const isLastRow - = displayedRowIndex + mergedRowSpan === rowCount - const isCrossRowTd = mergedRowSpan > 1 - if (isCrossRowTd) { - cordKey[displayedRowIndex] = { - [colIndex]: [] - } - } - if (mergedColSpan > 1 || isCrossRowTd) { - for ( - let i = displayedRowIndex; - i < displayedRowIndex + mergedRowSpan; - ++i - ) { - if (isCrossRowTd) { - cordKey[displayedRowIndex][colIndex].push( - rowIndexToKey[i] - ) - } - for ( - let j = colIndex; - j < colIndex + mergedColSpan; - ++j - ) { - if (i === displayedRowIndex && j === colIndex) { - continue - } - if (!(i in cordToPass)) { - cordToPass[i] = [j] - } - else { - cordToPass[i].push(j) - } - } - } - } - const hoverKey = isCrossRowTd ? this.hoverKey : null - const { cellProps } = column - const resolvedCellProps = cellProps?.( - rowData, - actualRowIndex - ) - const indentOffsetStyle = { - '--indent-offset': '' as string | number - } - return ( - - {hasChildren && colIndex === childTriggerColIndex - ? [ - repeat( - (indentOffsetStyle['--indent-offset'] - = isSummary ? 0 : rowInfo.tmNode.level), -
- ), - isSummary || rowInfo.tmNode.isLeaf ? ( -
- ) : ( - { - handleUpdateExpanded(rowKey, rowInfo.tmNode) - }} - /> - ) - ] - : null} - {column.type === 'selection' ? ( - !isSummary ? ( - column.multiple === false ? ( - { - handleRadioUpdateChecked(rowInfo.tmNode) - }} - /> - ) : ( - { - handleCheckboxUpdateChecked( - rowInfo.tmNode, - checked, - e.shiftKey - ) - }} - /> - ) - ) : null - ) : column.type === 'expand' ? ( - !isSummary ? ( - !column.expandable - || column.expandable?.(rowData) ? ( - { - handleUpdateExpanded(rowKey, null) - }} - /> - ) : null - ) : null - ) : ( - - )} - - ) - })} + {cells} ) - return row } @@ -975,7 +1054,17 @@ export default defineComponent({ class={`${mergedClsPrefix}-data-table-tbody`} > {displayedData.map((rowInfo, displayedRowIndex) => { - return renderRow(rowInfo, displayedRowIndex, false) + return renderRow({ + rowInfo, + displayedRowIndex, + isVirtual: false, + isVirtualX: false, + startColIndex: -1, + endColIndex: -1, + getLeft(_index) { + return -1 + } + }) })} ) : null} @@ -987,7 +1076,7 @@ export default defineComponent({ { + return renderRow({ + displayedRowIndex: itemIndex, + isVirtual: true, + isVirtualX: true, + rowInfo: item as RowRenderInfo, + startColIndex, + endColIndex, + getLeft + }) + } + : undefined + } > {{ default: ({ item, - index + index, + renderedItemWithCols }: { item: RowRenderInfo index: number - }) => renderRow(item, index, true) + renderedItemWithCols: VNodeChild + }) => { + if (renderedItemWithCols) + return renderedItemWithCols + return renderRow({ + rowInfo: item, + displayedRowIndex: index, + isVirtual: true, + isVirtualX: false, + startColIndex: 0, + endColIndex: 0, + getLeft(_index) { + return 0 + } + }) + } }} ) diff --git a/src/data-table/src/TableParts/Header.tsx b/src/data-table/src/TableParts/Header.tsx index 6aa71931bd0..cfcc61f9633 100644 --- a/src/data-table/src/TableParts/Header.tsx +++ b/src/data-table/src/TableParts/Header.tsx @@ -1,13 +1,8 @@ -import { - Fragment, - type VNode, - type VNodeChild, - defineComponent, - h, - inject, - ref -} from 'vue' +import { Fragment, defineComponent, h, inject, ref } from 'vue' +import type { PropType, VNode, VNodeChild } from 'vue' import { happensIn, pxfy } from 'seemly' +import type { VirtualListInst } from 'vueuc' +import { VVirtualList } from 'vueuc' import { formatLength } from '../../../_utils' import { NCheckbox } from '../../../checkbox' import { NEllipsis } from '../../../ellipsis' @@ -30,6 +25,7 @@ import { type TableExpandColumn, dataTableInjectionKey } from '../interface' +import type { ColItem, RowItem } from '../use-group-header' import SelectionMenu from './SelectionMenu' function renderTitle( @@ -40,6 +36,42 @@ function renderTitle( : column.title } +const VirtualListItemWrapper = defineComponent({ + props: { + clsPrefix: { + type: String, + required: true + }, + id: { + type: String, + required: true + }, + cols: { + type: Array as PropType, + required: true + }, + width: String + }, + render() { + const { clsPrefix, id, cols, width } = this + return ( + + + {cols.map(col => ( + + ))} + + + {this.$slots} + +
+ ) + } +}) + export default defineComponent({ name: 'DataTableHeader', props: { @@ -65,6 +97,8 @@ export default defineComponent({ componentId, mergedTableLayoutRef, headerCheckboxDisabledRef, + virtualScrollHeaderRef, + headerHeightRef, onUnstableColumnResize, doUpdateResizableWidth, handleTableHeaderScroll, @@ -72,6 +106,7 @@ export default defineComponent({ doUncheckAll, doCheckAll } = inject(dataTableInjectionKey)! + const virtualListRef = ref() const cellElsRef = ref>({}) function getCellActualWidth(key: ColumnKey): number | undefined { const element = cellElsRef.value[key] @@ -147,6 +182,9 @@ export default defineComponent({ checkOptions: checkOptionsRef, mergedTableLayout: mergedTableLayoutRef, headerCheckboxDisabled: headerCheckboxDisabledRef, + headerHeight: headerHeightRef, + virtualScrollHeader: virtualScrollHeaderRef, + virtualListRef, handleCheckboxUpdateChecked, handleColHeaderClick, handleTableHeaderScroll, @@ -172,12 +210,233 @@ export default defineComponent({ mergedTableLayout, headerCheckboxDisabled, mergedSortState, + virtualScrollHeader, handleColHeaderClick, handleCheckboxUpdateChecked, handleColumnResizeStart, handleColumnResize } = this let hasEllipsis = false + + const renderRow = ( + row: RowItem[], + getLeft: ((index: number) => number) | null, + headerHeightPx: string | undefined + ) => + row.map(({ column, colIndex, colSpan, rowSpan, isLast }) => { + const key = getColKey(column) + const { ellipsis } = column + if (!hasEllipsis && ellipsis) + hasEllipsis = true + const createColumnVNode = (): VNode | null => { + if (column.type === 'selection') { + return column.multiple !== false ? ( + <> + + {checkOptions ? ( + + ) : null} + + ) : null + } + return ( + <> +
+
+ {ellipsis === true || (ellipsis && !ellipsis.tooltip) ? ( +
+ {renderTitle(column)} +
+ ) : ellipsis && typeof ellipsis === 'object' ? ( + + {{ + default: () => renderTitle(column) + }} + + ) : ( + renderTitle(column) + )} +
+ {isColumnSortable(column) ? ( + + ) : null} +
+ {isColumnFilterable(column) ? ( + + ) : null} + {isColumnResizable(column) ? ( + { + handleColumnResizeStart(column as TableBaseColumn) + }} + onResize={(displacementX) => { + handleColumnResize(column as TableBaseColumn, displacementX) + }} + /> + ) : null} + + ) + } + const leftFixed = key in fixedColumnLeftMap + const rightFixed = key in fixedColumnRightMap + const CellComponent = (getLeft && !column.fixed ? 'div' : 'th') as 'th' + return ( + (cellElsRef[key] = el as HTMLTableCellElement)} + key={key} + style={[ + getLeft && !column.fixed + ? { + position: 'absolute', + left: pxfy(getLeft(colIndex)), + top: 0, + bottom: 0 + } + : { + left: pxfy(fixedColumnLeftMap[key]?.start), + right: pxfy(fixedColumnRightMap[key]?.start) + }, + { + width: pxfy(column.width), + textAlign: column.titleAlign || column.align, + height: headerHeightPx + } + ]} + colspan={colSpan} + rowspan={rowSpan} + data-col-key={key} + class={[ + `${mergedClsPrefix}-data-table-th`, + (leftFixed || rightFixed) + && `${mergedClsPrefix}-data-table-th--fixed-${ + leftFixed ? 'left' : 'right' + }`, + { + [`${mergedClsPrefix}-data-table-th--sorting`]: isColumnSorting( + column, + mergedSortState + ), + [`${mergedClsPrefix}-data-table-th--filterable`]: + isColumnFilterable(column), + [`${mergedClsPrefix}-data-table-th--sortable`]: + isColumnSortable(column), + [`${mergedClsPrefix}-data-table-th--selection`]: + column.type === 'selection', + [`${mergedClsPrefix}-data-table-th--last`]: isLast + }, + column.className + ]} + onClick={ + column.type !== 'selection' + && column.type !== 'expand' + && !('children' in column) + ? (e) => { + handleColHeaderClick(e, column) + } + : undefined + } + > + {createColumnVNode()} + + ) + }) + + if (virtualScrollHeader) { + const { headerHeight } = this + + let leftFixedColsCount = 0 + let rightFixedColsCount = 0 + + cols.forEach((col) => { + if (col.column.fixed === 'left') { + leftFixedColsCount++ + } + else if (col.column.fixed === 'right') { + rightFixedColsCount++ + } + }) + + return ( + { + const row = cols + .map((col, index) => { + return { + column: col.column, + isLast: index === cols.length - 1, + colIndex: col.index, + colSpan: 1, + rowSpan: 1 + } + }) + .filter(({ column }, index) => { + if (startColIndex <= index && index <= endColIndex) { + return true + } + if (column.fixed) { + return true + } + return false + }) + + const cells = renderRow(row, getLeft, pxfy(headerHeight)) + + cells.splice( + leftFixedColsCount, + 0, + + ) + return {cells} + }} + > + {{ + default: ({ + renderedItemWithCols + }: { + renderedItemWithCols: VNodeChild + }) => renderedItemWithCols + }} + + ) + } + const theadVNode = ( { return ( - {row.map(({ column, colSpan, rowSpan, isLast }) => { - const key = getColKey(column) - const { ellipsis } = column - if (!hasEllipsis && ellipsis) - hasEllipsis = true - const createColumnVNode = (): VNode | null => { - if (column.type === 'selection') { - return column.multiple !== false ? ( - <> - - {checkOptions ? ( - - ) : null} - - ) : null - } - return ( - <> -
-
- {ellipsis === true - || (ellipsis && !ellipsis.tooltip) ? ( -
- {renderTitle(column)} -
- ) : ellipsis && typeof ellipsis === 'object' ? ( - - {{ - default: () => renderTitle(column) - }} - - ) : ( - renderTitle(column) - )} -
- {isColumnSortable(column) ? ( - - ) : null} -
- {isColumnFilterable(column) ? ( - - ) : null} - {isColumnResizable(column) ? ( - { - handleColumnResizeStart(column as TableBaseColumn) - }} - onResize={(displacementX) => { - handleColumnResize( - column as TableBaseColumn, - displacementX - ) - }} - /> - ) : null} - - ) - } - const leftFixed = key in fixedColumnLeftMap - const rightFixed = key in fixedColumnRightMap - return ( - (cellElsRef[key] = el as HTMLTableCellElement)} - key={key} - style={{ - textAlign: column.titleAlign || column.align, - left: pxfy(fixedColumnLeftMap[key]?.start), - right: pxfy(fixedColumnRightMap[key]?.start) - }} - colspan={colSpan} - rowspan={rowSpan} - data-col-key={key} - class={[ - `${mergedClsPrefix}-data-table-th`, - (leftFixed || rightFixed) - && `${mergedClsPrefix}-data-table-th--fixed-${ - leftFixed ? 'left' : 'right' - }`, - { - [`${mergedClsPrefix}-data-table-th--sorting`]: - isColumnSorting(column, mergedSortState), - [`${mergedClsPrefix}-data-table-th--filterable`]: - isColumnFilterable(column), - [`${mergedClsPrefix}-data-table-th--sortable`]: - isColumnSortable(column), - [`${mergedClsPrefix}-data-table-th--selection`]: - column.type === 'selection', - [`${mergedClsPrefix}-data-table-th--last`]: isLast - }, - column.className - ]} - onClick={ - column.type !== 'selection' - && column.type !== 'expand' - && !('children' in column) - ? (e) => { - handleColHeaderClick(e, column) - } - : undefined - } - > - {createColumnVNode()} - - ) - })} + {renderRow(row, null, undefined)} ) })} @@ -326,7 +461,6 @@ export default defineComponent({ onScroll={handleTableHeaderScroll} > , stickyExpandedRows: Boolean, virtualScroll: Boolean, + virtualScrollX: Boolean, + virtualScrollHeader: Boolean, + headerHeight: Number, + heightForRow: Function as PropType, + minRowHeight: Number, tableLayout: { type: String as PropType<'auto' | 'fixed'>, default: 'auto' @@ -231,6 +237,11 @@ export interface CommonColumnInfo { cellProps?: (rowData: T, rowIndex: number) => HTMLAttributes } +export type DataTableHeightForRow = ( + rowData: T, + rowIndex: number +) => number + export type TableColumnTitle = | string | ((column: TableBaseColumn) => VNodeChild) @@ -384,6 +395,11 @@ export interface DataTableInjection { summaryRef: Ref rawPaginatedDataRef: Ref virtualScrollRef: Ref + virtualScrollXRef: Ref + minRowHeightRef: Ref + heightForRowRef: Ref + virtualScrollHeaderRef: Ref + headerHeightRef: Ref bodyWidthRef: Ref mergedTableLayoutRef: Ref<'auto' | 'fixed'> maxHeightRef: Ref @@ -502,6 +518,7 @@ export interface MainTableBodyRef { export interface MainTableHeaderRef { $el: HTMLElement | null + virtualListRef: Ref } export type OnFilterMenuChange = < diff --git a/src/data-table/src/styles/index.cssr.ts b/src/data-table/src/styles/index.cssr.ts index 9d4ca9f3c53..ba321f978c7 100644 --- a/src/data-table/src/styles/index.cssr.ts +++ b/src/data-table/src/styles/index.cssr.ts @@ -184,6 +184,7 @@ export default c([ background-color: var(--n-merged-th-color); `), cB('data-table-tr', ` + position: relative; box-sizing: border-box; background-clip: padding-box; transition: background-color .3s var(--n-bezier); diff --git a/src/data-table/src/use-group-header.ts b/src/data-table/src/use-group-header.ts index 4ca77b19c4c..1b4b55244f8 100644 --- a/src/data-table/src/use-group-header.ts +++ b/src/data-table/src/use-group-header.ts @@ -15,12 +15,18 @@ export interface RowItem { colSpan: number rowSpan: number column: TableColumn + colIndex: number isLast: boolean } export interface ColItem { key: string | number style: CSSProperties column: TableSelectionColumn | TableExpandColumn | TableBaseColumn + index: number + /** + * The width property is only applied to horizontally virtual scroll table + */ + width: number } type RowItemMap = WeakMap @@ -49,7 +55,7 @@ function getRowsAndCols( rows[currentDepth] = [] maxDepth = currentDepth } - for (const column of columns) { + columns.forEach((column, index) => { if ('children' in column) { ensureMaxDepth(column.children, currentDepth + 1) } @@ -61,7 +67,10 @@ function getRowsAndCols( column, key !== undefined ? formatLength(getResizableWidth(key)) : undefined ), - column + column, + index, + // The width property is only applied to horizontally virtual scroll table + width: column.width === undefined ? 128 : Number(column.width) }) totalRowSpan += 1 if (!hasEllipsis) { @@ -69,7 +78,7 @@ function getRowsAndCols( } dataRelatedCols.push(column) } - } + }) } ensureMaxDepth(columns, 0) let currentLeafIndex = 0 @@ -82,6 +91,7 @@ function getRowsAndCols( const cachedCurrentLeafIndex = currentLeafIndex const rowItem: RowItem = { column, + colIndex: currentLeafIndex, colSpan: 0, rowSpan: 1, isLast: false @@ -112,6 +122,7 @@ function getRowsAndCols( const rowItem: RowItem = { column, colSpan, + colIndex: currentLeafIndex, rowSpan: maxDepth - currentDepth + 1, isLast }