feat(data-table): supports horizontal virtual scrolling

This commit is contained in:
07akioni 2024-09-24 02:05:42 +08:00
parent 2e453472d8
commit 17b96c9027
12 changed files with 821 additions and 354 deletions

View File

@ -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",

View File

@ -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

View File

@ -0,0 +1,82 @@
<markdown>
# 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.
</markdown>
<script lang="ts">
import { defineComponent } from 'vue'
import type { DataTableColumns } from 'naive-ui'
interface RowData {
key: number
name: string
age: number
address: string
}
const columns: DataTableColumns<RowData> = []
let scrollX = 0
for (let i = 0; i < 1000; ++i) {
scrollX += 100
columns.push({
title: `Col ${i}`,
width: 100,
key: i,
fixed: i <= 1 ? 'left' : i > 997 ? 'right' : undefined,
render(_, rowIndex) {
return `${i}-${rowIndex}`
}
})
}
export default defineComponent({
setup() {
const data: RowData[] = Array.from({ length: 1000 }).map((_, index) => ({
key: index,
name: `Edward King ${index}`,
age: 32,
address: `London, Park Lane no. ${index}`
}))
return {
data,
columns,
scrollX,
minRowHeight: 48,
heightForRow: () => 48
}
}
})
</script>
<template>
<n-data-table
:columns="columns"
:data="data"
:max-height="250"
virtual-scroll
virtual-scroll-x
:scroll-x="scrollX"
:min-row-height="48"
:height-for-row="heightForRow"
virtual-scroll-header
:header-height="48"
/>
</template>

View File

@ -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

View File

@ -0,0 +1,82 @@
<markdown>
# 大量数据行和列
如果你有大量行数据和列数据例如几千行 + 几百列`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` 的表格可以轻松的支持千万级的表格数据你在不收钱的组件库不容易找得到这样的功能
</markdown>
<script lang="ts">
import { defineComponent } from 'vue'
import type { DataTableColumns } from 'naive-ui'
interface RowData {
key: number
name: string
age: number
address: string
}
const columns: DataTableColumns<RowData> = []
let scrollX = 0
for (let i = 0; i < 1000; ++i) {
scrollX += 100
columns.push({
title: `Col ${i}`,
width: 100,
key: i,
fixed: i <= 1 ? 'left' : i > 997 ? 'right' : undefined,
render(_, rowIndex) {
return `${i}-${rowIndex}`
}
})
}
export default defineComponent({
setup() {
const data: RowData[] = Array.from({ length: 1000 }).map((_, index) => ({
key: index,
name: `Edward King ${index}`,
age: 32,
address: `London, Park Lane no. ${index}`
}))
return {
data,
columns,
scrollX,
minRowHeight: 48,
heightForRow: () => 48
}
}
})
</script>
<template>
<n-data-table
:columns="columns"
:data="data"
:max-height="250"
virtual-scroll
virtual-scroll-x
:scroll-x="scrollX"
:min-row-height="48"
:height-for-row="heightForRow"
virtual-scroll-header
:header-height="48"
/>
</template>

View File

@ -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(() => {

View File

@ -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
}

View File

@ -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 (
<FinalCellComponent
{...resolvedCellProps}
key={colKey}
style={[
{
textAlign: column.align || undefined,
width: pxfy(column.width)
},
isVirtualX && {
height: virtualXRowHeight
},
isVirtualX && !column.fixed
? {
position: 'absolute',
left: pxfy(getLeft(colIndex)),
top: 0,
bottom: 0
}
: {
left: pxfy(fixedColumnLeftMap[colKey]?.start),
right: pxfy(fixedColumnRightMap[colKey]?.start)
},
indentOffsetStyle as CSSProperties,
resolvedCellProps?.style || ''
]}
colspan={mergedColSpan}
rowspan={isVirtual ? undefined : mergedRowSpan}
data-col-key={colKey}
class={[
`${mergedClsPrefix}-data-table-td`,
column.className,
resolvedCellProps?.class,
isSummary && `${mergedClsPrefix}-data-table-td--summary`,
hoverKey !== null
&& cordKey[displayedRowIndex][colIndex].includes(
hoverKey
)
&& `${mergedClsPrefix}-data-table-td--hover`,
isColumnSorting(column, mergedSortState)
&& `${mergedClsPrefix}-data-table-td--sorting`,
column.fixed
&& `${mergedClsPrefix}-data-table-td--fixed-${column.fixed}`,
column.align
&& `${mergedClsPrefix}-data-table-td--${column.align}-align`,
column.type === 'selection'
&& `${mergedClsPrefix}-data-table-td--selection`,
column.type === 'expand'
&& `${mergedClsPrefix}-data-table-td--expand`,
isLastCol && `${mergedClsPrefix}-data-table-td--last-col`,
isLastRow && `${mergedClsPrefix}-data-table-td--last-row`
]}
>
{hasChildren && colIndex === childTriggerColIndex
? [
repeat(
(indentOffsetStyle['--indent-offset'] = isSummary
? 0
: rowInfo.tmNode.level),
<div
class={`${mergedClsPrefix}-data-table-indent`}
style={indentStyle}
/>
),
isSummary || rowInfo.tmNode.isLeaf ? (
<div
class={`${mergedClsPrefix}-data-table-expand-placeholder`}
/>
) : (
<ExpandTrigger
class={`${mergedClsPrefix}-data-table-expand-trigger`}
clsPrefix={mergedClsPrefix}
expanded={expanded}
rowData={rowData}
renderExpandIcon={this.renderExpandIcon}
loading={loadingKeySet.has(rowInfo.key)}
onClick={() => {
handleUpdateExpanded(rowKey, rowInfo.tmNode)
}}
/>
)
]
: null}
{column.type === 'selection' ? (
!isSummary ? (
column.multiple === false ? (
<RenderSafeRadio
key={currentPage}
rowKey={rowKey}
disabled={rowInfo.tmNode.disabled}
onUpdateChecked={() => {
handleRadioUpdateChecked(rowInfo.tmNode)
}}
/>
) : (
<RenderSafeCheckbox
key={currentPage}
rowKey={rowKey}
disabled={rowInfo.tmNode.disabled}
onUpdateChecked={(checked: boolean, e) => {
handleCheckboxUpdateChecked(
rowInfo.tmNode,
checked,
e.shiftKey
)
}}
/>
)
) : null
) : column.type === 'expand' ? (
!isSummary ? (
!column.expandable || column.expandable?.(rowData) ? (
<ExpandTrigger
clsPrefix={mergedClsPrefix}
rowData={rowData}
expanded={expanded}
renderExpandIcon={this.renderExpandIcon}
onClick={() => {
handleUpdateExpanded(rowKey, null)
}}
/>
) : null
) : null
) : (
<Cell
clsPrefix={mergedClsPrefix}
index={actualRowIndex}
row={rowData}
column={column}
isSummary={isSummary}
mergedTheme={mergedTheme}
renderCell={this.renderCell}
/>
)}
</FinalCellComponent>
)
})
if (isVirtualX) {
if (leftFixedColsCount && rightFixedColsCount) {
cells.splice(
leftFixedColsCount,
0,
<td
colspan={
cols.length - leftFixedColsCount - rightFixedColsCount
}
style={{
pointerEvents: 'none',
visibility: 'hidden',
height: 0
}}
/>
)
}
}
const row = (
<tr
onMouseenter={() => {
@ -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 (
<td
{...resolvedCellProps}
key={colKey}
style={[
{
textAlign: column.align || undefined,
left: pxfy(fixedColumnLeftMap[colKey]?.start),
right: pxfy(fixedColumnRightMap[colKey]?.start)
},
indentOffsetStyle as CSSProperties,
resolvedCellProps?.style || ''
]}
colspan={mergedColSpan}
rowspan={isVirtual ? undefined : mergedRowSpan}
data-col-key={colKey}
class={[
`${mergedClsPrefix}-data-table-td`,
column.className,
resolvedCellProps?.class,
isSummary
&& `${mergedClsPrefix}-data-table-td--summary`,
hoverKey !== null
&& cordKey[displayedRowIndex][colIndex].includes(
hoverKey
)
&& `${mergedClsPrefix}-data-table-td--hover`,
isColumnSorting(column, mergedSortState)
&& `${mergedClsPrefix}-data-table-td--sorting`,
column.fixed
&& `${mergedClsPrefix}-data-table-td--fixed-${column.fixed}`,
column.align
&& `${mergedClsPrefix}-data-table-td--${column.align}-align`,
column.type === 'selection'
&& `${mergedClsPrefix}-data-table-td--selection`,
column.type === 'expand'
&& `${mergedClsPrefix}-data-table-td--expand`,
isLastCol
&& `${mergedClsPrefix}-data-table-td--last-col`,
isLastRow
&& `${mergedClsPrefix}-data-table-td--last-row`
]}
>
{hasChildren && colIndex === childTriggerColIndex
? [
repeat(
(indentOffsetStyle['--indent-offset']
= isSummary ? 0 : rowInfo.tmNode.level),
<div
class={`${mergedClsPrefix}-data-table-indent`}
style={indentStyle}
/>
),
isSummary || rowInfo.tmNode.isLeaf ? (
<div
class={`${mergedClsPrefix}-data-table-expand-placeholder`}
/>
) : (
<ExpandTrigger
class={`${mergedClsPrefix}-data-table-expand-trigger`}
clsPrefix={mergedClsPrefix}
expanded={expanded}
rowData={rowData}
renderExpandIcon={this.renderExpandIcon}
loading={loadingKeySet.has(rowInfo.key)}
onClick={() => {
handleUpdateExpanded(rowKey, rowInfo.tmNode)
}}
/>
)
]
: null}
{column.type === 'selection' ? (
!isSummary ? (
column.multiple === false ? (
<RenderSafeRadio
key={currentPage}
rowKey={rowKey}
disabled={rowInfo.tmNode.disabled}
onUpdateChecked={() => {
handleRadioUpdateChecked(rowInfo.tmNode)
}}
/>
) : (
<RenderSafeCheckbox
key={currentPage}
rowKey={rowKey}
disabled={rowInfo.tmNode.disabled}
onUpdateChecked={(checked: boolean, e) => {
handleCheckboxUpdateChecked(
rowInfo.tmNode,
checked,
e.shiftKey
)
}}
/>
)
) : null
) : column.type === 'expand' ? (
!isSummary ? (
!column.expandable
|| column.expandable?.(rowData) ? (
<ExpandTrigger
clsPrefix={mergedClsPrefix}
rowData={rowData}
expanded={expanded}
renderExpandIcon={this.renderExpandIcon}
onClick={() => {
handleUpdateExpanded(rowKey, null)
}}
/>
) : null
) : null
) : (
<Cell
clsPrefix={mergedClsPrefix}
index={actualRowIndex}
row={rowData}
column={column}
isSummary={isSummary}
mergedTheme={mergedTheme}
renderCell={this.renderCell}
/>
)}
</td>
)
})}
{cells}
</tr>
)
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
}
})
})}
</tbody>
) : null}
@ -987,7 +1076,7 @@ export default defineComponent({
<VirtualList
ref="virtualListRef"
items={displayedData}
itemSize={28}
itemSize={this.minRowHeight || 28}
visibleItemsTag={VirtualListItemWrapper}
visibleItemsProps={{
clsPrefix: mergedClsPrefix,
@ -999,16 +1088,54 @@ export default defineComponent({
onResize={this.handleVirtualListResize}
onScroll={this.handleVirtualListScroll}
itemsStyle={contentStyle}
itemResizable
itemResizable={!virtualScrollX}
columns={cols}
renderItemWithCols={
virtualScrollX
? ({
itemIndex,
item,
startColIndex,
endColIndex,
getLeft
}) => {
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
}
})
}
}}
</VirtualList>
)

View File

@ -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<ColItem[]>,
required: true
},
width: String
},
render() {
const { clsPrefix, id, cols, width } = this
return (
<table
style={{ tableLayout: 'fixed', width }}
class={`${clsPrefix}-data-table-table`}
>
<colgroup>
{cols.map(col => (
<col key={col.key} style={col.style}></col>
))}
</colgroup>
<thead data-n-id={id} class={`${clsPrefix}-data-table-thead`}>
{this.$slots}
</thead>
</table>
)
}
})
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<VirtualListInst | null>()
const cellElsRef = ref<Record<ColumnKey, HTMLTableCellElement>>({})
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 ? (
<>
<NCheckbox
key={currentPage}
privateInsideTable
checked={allRowsChecked}
indeterminate={someRowsChecked}
disabled={headerCheckboxDisabled}
onUpdateChecked={handleCheckboxUpdateChecked}
/>
{checkOptions ? (
<SelectionMenu clsPrefix={mergedClsPrefix} />
) : null}
</>
) : null
}
return (
<>
<div class={`${mergedClsPrefix}-data-table-th__title-wrapper`}>
<div class={`${mergedClsPrefix}-data-table-th__title`}>
{ellipsis === true || (ellipsis && !ellipsis.tooltip) ? (
<div class={`${mergedClsPrefix}-data-table-th__ellipsis`}>
{renderTitle(column)}
</div>
) : ellipsis && typeof ellipsis === 'object' ? (
<NEllipsis
{...ellipsis}
theme={mergedTheme.peers.Ellipsis}
themeOverrides={mergedTheme.peerOverrides.Ellipsis}
>
{{
default: () => renderTitle(column)
}}
</NEllipsis>
) : (
renderTitle(column)
)}
</div>
{isColumnSortable(column) ? (
<SortButton column={column as TableBaseColumn} />
) : null}
</div>
{isColumnFilterable(column) ? (
<FilterButton
column={column as TableBaseColumn}
options={column.filterOptions}
/>
) : null}
{isColumnResizable(column) ? (
<ResizeButton
onResizeStart={() => {
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 (
<CellComponent
ref={el => (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()}
</CellComponent>
)
})
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 (
<VVirtualList
ref="virtualListRef"
class={`${mergedClsPrefix}-data-table-base-table-header`}
style={{ height: pxfy(headerHeight) }}
onScroll={this.handleTableHeaderScroll}
columns={cols}
itemSize={headerHeight || 28}
showScrollbar={false}
items={[{}]}
itemResizable={false}
visibleItemsTag={VirtualListItemWrapper}
visibleItemsProps={{
clsPrefix: mergedClsPrefix,
id: componentId,
cols,
width: formatLength(this.scrollX)
}}
renderItemWithCols={({ startColIndex, endColIndex, getLeft }) => {
const row = cols
.map<RowItem>((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,
<th
colspan={cols.length - leftFixedColsCount - rightFixedColsCount}
style={{
pointerEvents: 'none',
visibility: 'hidden',
height: 0
}}
/>
)
return <tr style={{ position: 'relative' }}>{cells}</tr>
}}
>
{{
default: ({
renderedItemWithCols
}: {
renderedItemWithCols: VNodeChild
}) => renderedItemWithCols
}}
</VVirtualList>
)
}
const theadVNode = (
<thead
class={`${mergedClsPrefix}-data-table-thead`}
@ -186,131 +445,7 @@ export default defineComponent({
{rows.map((row) => {
return (
<tr class={`${mergedClsPrefix}-data-table-tr`}>
{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 ? (
<>
<NCheckbox
key={currentPage}
privateInsideTable
checked={allRowsChecked}
indeterminate={someRowsChecked}
disabled={headerCheckboxDisabled}
onUpdateChecked={handleCheckboxUpdateChecked}
/>
{checkOptions ? (
<SelectionMenu clsPrefix={mergedClsPrefix} />
) : null}
</>
) : null
}
return (
<>
<div
class={`${mergedClsPrefix}-data-table-th__title-wrapper`}
>
<div class={`${mergedClsPrefix}-data-table-th__title`}>
{ellipsis === true
|| (ellipsis && !ellipsis.tooltip) ? (
<div
class={`${mergedClsPrefix}-data-table-th__ellipsis`}
>
{renderTitle(column)}
</div>
) : ellipsis && typeof ellipsis === 'object' ? (
<NEllipsis
{...ellipsis}
theme={mergedTheme.peers.Ellipsis}
themeOverrides={
mergedTheme.peerOverrides.Ellipsis
}
>
{{
default: () => renderTitle(column)
}}
</NEllipsis>
) : (
renderTitle(column)
)}
</div>
{isColumnSortable(column) ? (
<SortButton column={column as TableBaseColumn} />
) : null}
</div>
{isColumnFilterable(column) ? (
<FilterButton
column={column as TableBaseColumn}
options={column.filterOptions}
/>
) : null}
{isColumnResizable(column) ? (
<ResizeButton
onResizeStart={() => {
handleColumnResizeStart(column as TableBaseColumn)
}}
onResize={(displacementX) => {
handleColumnResize(
column as TableBaseColumn,
displacementX
)
}}
/>
) : null}
</>
)
}
const leftFixed = key in fixedColumnLeftMap
const rightFixed = key in fixedColumnRightMap
return (
<th
ref={el => (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()}
</th>
)
})}
{renderRow(row, null, undefined)}
</tr>
)
})}
@ -326,7 +461,6 @@ export default defineComponent({
onScroll={handleTableHeaderScroll}
>
<table
ref="body"
class={`${mergedClsPrefix}-data-table-table`}
style={{
minWidth: formatLength(scrollX),

View File

@ -8,6 +8,7 @@ import type {
Slots,
VNodeChild
} from 'vue'
import type { VirtualListInst } from 'vueuc'
import type { ScrollTo, ScrollbarProps } from '../../scrollbar/src/Scrollbar'
import type { EllipsisProps } from '../../ellipsis/src/Ellipsis'
import type { ExtractPublicPropTypes, MaybeArray } from '../../_utils'
@ -90,6 +91,11 @@ export const dataTableProps = {
expandedRowKeys: Array as PropType<RowKey[]>,
stickyExpandedRows: Boolean,
virtualScroll: Boolean,
virtualScrollX: Boolean,
virtualScrollHeader: Boolean,
headerHeight: Number,
heightForRow: Function as PropType<DataTableHeightForRow>,
minRowHeight: Number,
tableLayout: {
type: String as PropType<'auto' | 'fixed'>,
default: 'auto'
@ -231,6 +237,11 @@ export interface CommonColumnInfo<T = InternalRowData> {
cellProps?: (rowData: T, rowIndex: number) => HTMLAttributes
}
export type DataTableHeightForRow<T = RowData> = (
rowData: T,
rowIndex: number
) => number
export type TableColumnTitle =
| string
| ((column: TableBaseColumn) => VNodeChild)
@ -384,6 +395,11 @@ export interface DataTableInjection {
summaryRef: Ref<undefined | CreateSummary>
rawPaginatedDataRef: Ref<InternalRowData[]>
virtualScrollRef: Ref<boolean>
virtualScrollXRef: Ref<boolean>
minRowHeightRef: Ref<number | undefined>
heightForRowRef: Ref<DataTableHeightForRow | undefined>
virtualScrollHeaderRef: Ref<boolean>
headerHeightRef: Ref<number | undefined>
bodyWidthRef: Ref<number | null>
mergedTableLayoutRef: Ref<'auto' | 'fixed'>
maxHeightRef: Ref<string | number | undefined>
@ -502,6 +518,7 @@ export interface MainTableBodyRef {
export interface MainTableHeaderRef {
$el: HTMLElement | null
virtualListRef: Ref<VirtualListInst | null>
}
export type OnFilterMenuChange = <

View File

@ -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);

View File

@ -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<TableColumn, RowItem>
@ -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
}