Z-Table Virtual Scrolling
📚 Overview
The Z-Table component now supports virtual scrolling based on Element Plus TableV2, enabling efficient handling of large datasets and providing a smooth user experience.
🚀 Key Features
- High-performance rendering: Based on Element Plus TableV2, supports smooth scrolling with 100k+ rows
- Smart switching: Automatically or manually switch between regular and virtual tables
- Full functionality: Supports sorting, fixed columns, selection, etc.
- Consistent styles: Keeps the same visual experience as the original table
- Extreme performance: Constant number of DOM nodes, stable memory usage
✅ Compatibility
- Full compatibility: Sorting, filtering, selection, expand, index column, fixed columns, theme styles, column show/hide, column tooltip, watermark
- Advanced features: Editable table, custom column rendering, custom header, slot templates
- API consistency: Same props and events as the regular table
- Progressive enhancement: Can be enabled/disabled dynamically at runtime
🚀 Quick Start
Enable virtual scroll in 3 steps
<!-- Step 1: Add virtual prop -->
<!-- Step 2: Set a fixed height -->
<!-- Step 3: Configure columns -->
<z-table
:data="largeData"
:columns="columns"
:virtual="true"
height="400px"
/>Basic configuration example
<template>
<ZTable
:data="tableData"
:columns="columns"
:virtual="true"
height="600px"
/>
</template>
<script setup>
// No need to specify width for columns, handled automatically
const columns = [
{ prop: 'name', label: 'Name' },
{ prop: 'email', label: 'Email' },
{ prop: 'department', label: 'Department' }
]
</script>Advanced configuration example
<template>
<ZTable
:data="tableData"
:columns="columns"
:virtual="virtualConfig"
height="600px"
/>
</template>
<script setup>
const virtualConfig = {
enabled: true,
itemHeight: 48, // Row height
threshold: 100, // Enable threshold
footerHeight: 60 // Footer height
}
</script>🎯 Basic Features
Basic table
Basic virtual table functionality. Efficiently renders large data.
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { TableV2FixedDir } from 'element-plus'
// Generate mock data
function generateData(count: number) {
const data = []
for (let i = 1; i <= count; i++) {
data.push({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
phone: `138${String(i).padStart(8, '0')}`,
department: ['Engineering', 'Product', 'Design', 'Operations'][i % 4],
position: ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist'][i % 4],
status: ['Active', 'Departed', 'Probation'][i % 3],
createTime: new Date(2024, 0, 1 + (i % 365)).toLocaleDateString(),
})
}
return data
}
const tableData = ref(generateData(5000))
const dataCount = ref(5000)
const columns = ref([
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
fixed: TableV2FixedDir.LEFT
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'phone',
label: 'Phone',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'position',
label: 'Position',
},
{
prop: 'status',
label: 'Status',
},
{
prop: 'createTime',
label: 'Created At',
fixed: TableV2FixedDir.RIGHT,
},
])
// Virtual scrolling config
const virtualConfig = computed(() => ({
enabled: true,
itemHeight: 48,
threshold: 100,
}))
function updateData() {
tableData.value = generateData(dataCount.value)
}
function handleRefresh() {
console.log('Refresh data')
updateData()
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<el-space wrap>
<span>Row count:</span>
<el-input-number
v-model="dataCount"
:min="100"
:max="50000"
:step="1000"
controls-position="right"
style="width: 150px;"
/>
<el-button @click="updateData" type="primary">
Update data
</el-button>
<span style="color: #666;">Current: {{ tableData.length.toLocaleString() }} rows</span>
</el-space>
</div>
<z-table
v-model:data="tableData"
:columns="columns"
:virtual="virtualConfig"
height="500px"
border
stripe
@refresh="handleRefresh"
/>
</div>
</template>Operation buttons
Verify the full behavior of operation buttons under virtual table, including click events and styles.
Virtual table actions demo
Demonstrates action buttons inside a virtual table, including click handlers and styling.
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, h, ref, resolveComponent } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
interface RowData {
id: number
name: string
email: string
department: string
salary: number
status: string
}
// Generate mock data
function generateData(count: number): RowData[] {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
const statuses = ['Active', 'Departed', 'Probation']
const data: RowData[] = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
email: `user${i + 1}@example.com`,
department: departments[i % departments.length],
salary: Math.floor(Math.random() * 50000) + 8000,
status: statuses[i % statuses.length],
})
}
return data
}
const tableData = ref<RowData[]>(generateData(1000))
const columns = ref([
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'salary',
label: 'Salary',
render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
},
{
prop: 'status',
label: 'Status',
},
{
prop: 'operation',
label: 'Actions',
render: (scope: any) => [
h(resolveComponent('el-button'), {
type: 'primary',
onClick: () => handleEdit(scope.row)
}, () => 'Edit'),
h(resolveComponent('el-button'), {
type: 'danger',
onClick: () => handleDelete(scope.row)
}, () => 'Delete')
],
},
])
// Virtual scrolling config
const virtualConfig = computed(() => ({
enabled: true,
itemHeight: 48,
threshold: 100,
}))
function handleEdit(row: RowData) {
console.log('Edit action:', row)
ElMessage.success(`Editing: ${row.name}`)
}
function handleDelete(row: RowData) {
console.log('Delete action:', row)
ElMessageBox.confirm(`Delete ${row.name}?`, 'Confirm delete', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
}).then(() => {
const index = tableData.value.findIndex(item => item.id === row.id)
if (index > -1) {
tableData.value.splice(index, 1)
ElMessage.success('Deleted successfully')
}
}).catch(() => {
ElMessage.info('Deletion cancelled')
})
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h3>Virtual table actions demo</h3>
<p style="margin: 8px 0; color: #666;">
Demonstrates action buttons inside a virtual table, including click handlers and styling.
</p>
</div>
<z-table
v-model:data="tableData"
:columns="columns"
:virtual="virtualConfig"
height="500px"
border
stripe
/>
</div>
</template>Column Types
Test compatibility of various column types under virtual table: selection column, index column, form components, etc.
Virtual Table Column Type Demo
Demonstrates compatibility of various column types in a virtual table: selection columns, index columns, form components, and more.
Selected: 0 items
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, h, ref } from 'vue'
interface RowData {
id: number
name: string
email: string
department: string
position: string
salary: number
status: string
}
// Generate mock data
function generateData(count: number): RowData[] {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
const positions = ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist', 'Marketing Specialist']
const statuses = ['Active', 'Departed', 'Probation']
const data: RowData[] = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
email: `user${i + 1}@example.com`,
department: departments[i % departments.length],
position: positions[i % positions.length],
salary: Math.floor(Math.random() * 50000) + 8000,
status: statuses[i % statuses.length],
})
}
return data
}
const tableData = ref<RowData[]>(generateData(1000))
const columns = ref([
{
type: 'selection',
label: 'Selection',
},
{
type: 'index',
label: 'Index',
index: 1,
},
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'position',
label: 'Position',
},
{
prop: 'salary',
label: 'Salary',
render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
},
{
prop: 'status',
label: 'Status',
render: ({ row }: any) => {
const type = row.status === 'Active' ? 'success' : row.status === 'Probation' ? 'warning' : 'danger'
return h('el-tag', { type, size: 'small' }, row.status)
},
},
])
// Virtual scrolling config
const virtualConfig = computed(() => ({
enabled: true,
itemHeight: 48,
threshold: 100,
}))
const selectedRows = ref<RowData[]>([])
function handleSelectionChange(selection: RowData[]) {
selectedRows.value = selection
console.log('Selection changed:', selection)
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h3>Virtual Table Column Type Demo</h3>
<p style="margin: 8px 0; color: #666;">
Demonstrates compatibility of various column types in a virtual table: selection columns, index columns, form components, and more.
</p>
<p style="margin: 8px 0; color: #666;">Selected: {{ selectedRows.length }} items</p>
</div>
<z-table
v-model:data="tableData"
:columns="columns"
:virtual="virtualConfig"
height="500px"
border
stripe
@selection-change="handleSelectionChange"
/>
</div>
</template>Selection Column
Test support for type: 'selection' column under virtual table.
Virtual table selection demo
The virtual table fully supports multi-select operations including select-all, clearing, and custom actions.
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, ref } from 'vue'
interface RowData {
id: number
name: string
email: string
department: string
position: string
salary: number
status: string
}
// Generate mock data
function generateData(count: number): RowData[] {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
const positions = ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist', 'Marketing Specialist']
const statuses = ['Active', 'Departed', 'Probation']
const data: RowData[] = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
email: `user${i + 1}@example.com`,
department: departments[i % departments.length],
position: positions[i % positions.length],
salary: Math.floor(Math.random() * 50000) + 8000,
status: statuses[i % statuses.length],
})
}
return data
}
const tableData = ref<RowData[]>(generateData(2000))
const selectedRows = ref<RowData[]>([])
const columns = ref([
{
type: 'selection',
label: 'Selection',
},
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'position',
label: 'Position',
},
{
prop: 'salary',
label: 'Salary',
render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
},
{
prop: 'status',
label: 'Status',
},
])
// Virtual scrolling config
const virtualConfig = computed(() => ({
enabled: true,
itemHeight: 48,
threshold: 100,
}))
const tableRef = ref()
function handleSelectionChange(selection: RowData[]) {
console.log('Selection changed:', selection)
selectedRows.value = selection
}
function selectAll() {
tableRef.value?.toggleAllSelection()
}
function clearSelection() {
tableRef.value?.clearSelection()
selectedRows.value = []
}
function selectFirst10() {
clearSelection()
for (let i = 0; i < Math.min(10, tableData.value.length); i++) {
tableRef.value?.toggleRowSelection(tableData.value[i], true)
}
}
function selectHighSalary() {
clearSelection()
const highSalaryRows = tableData.value.filter(row => row.salary > 30000)
highSalaryRows.forEach(row => {
tableRef.value?.toggleRowSelection(row, true)
})
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h3>Virtual table selection demo</h3>
<p style="margin: 8px 0; color: #666;">
The virtual table fully supports multi-select operations including select-all, clearing, and custom actions.
</p>
<el-space wrap>
<el-button @click="selectAll" type="primary">
Select all
</el-button>
<el-button @click="clearSelection" type="warning">
Clear selection
</el-button>
<el-button @click="selectFirst10" type="success">
Select first 10
</el-button>
<el-button @click="selectHighSalary" type="info">
Select salary > 30k
</el-button>
<span style="color: #666;">Selected: {{ selectedRows.length }} rows</span>
</el-space>
</div>
<z-table
ref="tableRef"
v-model:data="tableData"
:columns="columns"
:virtual="virtualConfig"
height="500px"
border
stripe
@selection-change="handleSelectionChange"
/>
<div v-if="selectedRows.length > 0" style="padding: 16px; margin-top: 16px; background: #f5f7fa; border-radius: 4px;">
<h4 style="margin: 0 0 8px; color: #303133;">Selected rows ({{ selectedRows.length }}):</h4>
<div style="max-height: 200px; overflow-y: auto;">
<div
v-for="row in selectedRows"
:key="row.id"
style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
margin-bottom: 4px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 4px;
"
>
<span><strong>{{ row.name }}</strong> ({{ row.email }})</span>
<span style="color: #67c23a;">¥{{ row.salary.toLocaleString() }}</span>
</div>
</div>
</div>
</div>
</template>Expand Column
Virtual table fully supports expand functionality, leveraging Element Plus TableV2 native capabilities.
Features:
- Expand column config:
{ type: 'expand' }column config; TableV2 automatically adds expand button for rows with children - Two-way binding:
v-model:expanded-row-keyssupports two-way binding of expand state - Events:
@expand-change,@row-expand(native TableV2 events) - Methods:
toggleRowExpansion - Slots:
#expandslot to customize expand content - Data structure: Supports nested data structures with children field
Virtual Table – Expand Feature
Expand state:
Current expanded row IDs: None
Expanded row count: 0
<template>
<div>
<h3>Virtual Table – Expand Feature</h3>
<div style="margin-bottom: 16px;">
<el-space>
<el-button @click="expandAll">
Expand all
</el-button>
<el-button @click="collapseAll">
Collapse all
</el-button>
<el-button @click="expandFirst5">
Expand first 5 rows
</el-button>
</el-space>
</div>
<el-alert
title="✅ Expand feature ready"
type="success"
:closable="false"
style="margin-bottom: 16px;"
>
<template #default>
<p><strong>✅ Expand column support</strong>: Fully supports <code>type: 'expand'</code>; TableV2 handles rows with children automatically.</p>
<p><strong>✅ Two-way binding</strong>: <code>v-model:expanded-row-keys</code> keeps expanded state in sync.</p>
<p><strong>✅ Event support</strong>: <code>@expand-change</code> and <code>@row-expand</code> events.</p>
<p><strong>✅ Method support</strong>: <code>toggleRowExpansion</code> API.</p>
<p><strong>✅ Slot support</strong>: Customize expanded content via the <code>#expand</code> slot.</p>
</template>
</el-alert>
<z-table
ref="tableRef"
:data="tableData"
:columns="columns"
v-model:expanded-row-keys="expandedKeys"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100, estimatedRowHeight: 48 }"
height="500px"
border
stripe
row-key="id"
@expanded-rows-change="handleExpandedRowsChange"
@row-expand="handleRowExpand"
>
<template #expand="props">
<Row v-bind="props" />
</template>
</z-table>
<div style="margin-top: 16px;">
<h4>Expand state:</h4>
<p>Current expanded row IDs: {{ expandedKeys.join(', ') || 'None' }}</p>
<p>Expanded row count: {{ expandedKeys.length }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { h, ref } from 'vue'
const detailedText = `Detailed expand content goes here. Include work history, key skills, project experience, and other information.
This longer text renders nicely inside the expand area and supports rich text.
You can insert complex UI components and interactive elements as needed.`
// Generate mock data
function generateData() {
const data = []
for (let i = 1; i <= 2000; i++) {
const rowData: any = {
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
department: ['Engineering', 'Marketing', 'Product', 'Operations'][i % 4],
salary: 5000 + (i % 100) * 100,
createTime: new Date(2020 + (i % 4), (i % 12), (i % 28) + 1).toLocaleDateString(),
status: i % 3 === 0 ? 'active' : 'inactive',
}
// Add children for expand content
rowData.children = [
{
id: `${rowData.id}-detail-content`,
detail: detailedText,
parentData: rowData
}
]
data.push(rowData)
}
return data
}
const tableData = ref(generateData())
const expandedKeys = ref<(string | number)[]>([])
const tableRef = ref()
// Row component used to render expand content
function Row({ cells, rowData }: any) {
if (rowData.detail) {
return h('div', {
class: 'z-table-expand-content',
style: { padding: '20px', backgroundColor: '#f5f7fa' }
}, [
h('h4', {}, `${rowData.parentData?.name} details`),
h('div', { style: { marginTop: '16px' } }, [
h('p', {}, [
h('strong', {}, () => 'Description: '),
rowData.detail
]),
h('p', {}, [
h('strong', {}, () => 'Created at: '),
rowData.parentData?.createTime
]),
h('p', {}, [
h('strong', {}, () => 'Status: '),
h('span', {
style: {
color: rowData.parentData?.status === 'active' ? '#67C23A' : '#E6A23C',
fontWeight: 'bold'
}
}, rowData.parentData?.status === 'active' ? 'Active' : 'Inactive')
])
])
])
}
return cells
}
Row.inheritAttrs = false
// Column config
const columns = [
{
type: 'expand',
width: 60
},
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'age',
label: 'Age',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'salary',
label: 'Salary',
render: ({ row }: any) => `¥${row.salary.toLocaleString()}`
}
]
// Expanded row key change
function handleExpandedRowsChange(_keys: (string | number)[]) {
// console.log('Expanded row keys changed (expanded-rows-change):', _keys)
}
// Row expand event
function handleRowExpand(_params: any) {
// console.log('Row expand event (row-expand):', _params)
}
// Expand all
function expandAll() {
expandedKeys.value = tableData.value.map(row => row.id)
}
// Collapse all
function collapseAll() {
expandedKeys.value = []
}
// Expand first five rows
function expandFirst5() {
expandedKeys.value = tableData.value.slice(0, 5).map(row => row.id)
}
</script>
<style scoped>
.z-table-expand-content {
background-color: #f5f7fa;
}
</style>Index Column
Virtual table fully supports index column functionality.
Features:
- Index column config:
{ type: 'index' } - Custom start index: Supports numeric
indexprop - Function index: Supports function-type
indexfor custom display logic
Virtual Table Index Column Demo
The virtual table fully supports index columns, including numeric indexes and custom index functions.
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, ref } from 'vue'
interface RowData {
id: number
name: string
email: string
department: string
position: string
salary: number
}
// Generate mock data
function generateData(count: number): RowData[] {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
const positions = ['Engineer', 'Product Manager', 'Designer', 'Operations Specialist', 'Marketing Specialist']
const data: RowData[] = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
email: `user${i + 1}@example.com`,
department: departments[i % departments.length],
position: positions[i % positions.length],
salary: Math.floor(Math.random() * 50000) + 8000,
})
}
return data
}
const tableData = ref<RowData[]>(generateData(2000))
const indexType = ref('number')
const columns = computed(() => [
{
type: 'index',
label: 'Index',
index: indexType.value === 'number' ? 1 :
indexType.value === 'function' ? (index: number) => `No.${index + 1}` :
undefined,
},
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'position',
label: 'Position',
},
{
prop: 'salary',
label: 'Salary',
render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
},
])
// Virtual scrolling config
const virtualConfig = computed(() => ({
enabled: true,
itemHeight: 48,
threshold: 100,
}))
function changeIndexType(type: string) {
indexType.value = type
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h3>Virtual Table Index Column Demo</h3>
<p style="margin: 8px 0; color: #666;">
The virtual table fully supports index columns, including numeric indexes and custom index functions.
</p>
<el-space wrap>
<span>Index type:</span>
<el-radio-group v-model="indexType" @change="changeIndexType">
<el-radio-button value="number">Numeric index</el-radio-button>
<el-radio-button value="function">Function index</el-radio-button>
<el-radio-button value="default">Default index</el-radio-button>
</el-radio-group>
</el-space>
</div>
<z-table
v-model:data="tableData"
:columns="columns"
:virtual="virtualConfig"
height="500px"
border
stripe
/>
</div>
</template>
<style scoped>
ul {
padding-left: 20px;
margin: 0;
}
li {
margin: 4px 0;
}
</style>Editable Table
Test table editing under large datasets, supporting multiple form components.
Editable table demo (800 rows)
💡 Hint:
- Supports numerous form components: input, select, number input, date picker, etc.
- All edits sync to the data model in real time.
- Virtual scrolling keeps editing smooth with large datasets.
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { ref } from 'vue'
// Generate a large dataset
function generateLargeData(count: number) {
const names = ['Steven', 'Helen', 'Nancy', 'Jack', 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
const genders = ['1', '2']
const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
const data = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
gender: genders[i % genders.length],
age: 18 + (i % 50),
time: `202${(i % 4) + 0}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
salary: Math.floor(Math.random() * 50000) + 5000,
department: departments[i % departments.length],
})
}
return data
}
const tableData = ref(generateLargeData(800))
const columns = ref([
{
prop: 'id',
label: 'ID',
width: 100,
},
{
component: 'input',
prop: 'name',
label: 'Name',
fieldProps: {
placeholder: 'Please enter a name',
},
},
{
component: 'select',
prop: 'gender',
label: 'Gender',
fieldProps: {
placeholder: 'Please select a gender',
},
},
{
component: 'el-input-number',
prop: 'age',
label: 'Age',
fieldProps: {
min: 18,
max: 65,
},
},
{
component: 'el-input-number',
prop: 'salary',
label: 'Salary',
fieldProps: {
min: 0,
controls: false,
},
},
{
component: 'select',
prop: 'department',
label: 'Department',
},
{
component: 'el-date-picker',
prop: 'time',
label: 'Hire Date',
fieldProps: {
valueFormat: 'YYYY-MM-DD',
placeholder: 'Select date',
},
},
])
const options = {
gender: [
{ label: 'Male', value: '1' },
{ label: 'Female', value: '2' },
],
department: [
{ label: 'Engineering', value: 'Engineering' },
{ label: 'Product', value: 'Product' },
{ label: 'Design', value: 'Design' },
{ label: 'Operations', value: 'Operations' },
{ label: 'Marketing', value: 'Marketing' },
],
}
function handleSave() {
console.log('Save table data:', tableData.value)
}
function handleReset() {
tableData.value = generateLargeData(800)
console.log('Data reset complete')
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h4>Editable table demo (800 rows)</h4>
<el-space>
<el-button type="primary" @click="handleSave">
Save data
</el-button>
<el-button @click="handleReset">
Reset data
</el-button>
</el-space>
</div>
<z-table
v-model:data="tableData"
:columns="columns"
:options="options"
:editable="true"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
height="500px"
border
stripe
/>
<div style="margin-top: 16px; color: #666; font-size: 14px;">
<p>💡 Hint:</p>
<ul>
<li>Supports numerous form components: input, select, number input, date picker, etc.</li>
<li>All edits sync to the data model in real time.</li>
<li>Virtual scrolling keeps editing smooth with large datasets.</li>
</ul>
</div>
</div>
</template>Form Component Support
Virtual table fully supports various form components, including input, select, radio, checkbox, date picker, switch, etc.
Virtual Table Form Components Demo
Form component mapping:
- Name: input field
- Gender: select dropdown
- Age: input-number
- Department: multi-select
- Rating: radio group
- Skills: checkbox group
- Hire date: date picker
- Status: switch
<!-- eslint-disable no-console -->
<template>
<div class="virtual-form-components-demo">
<h2>Virtual Table Form Components Demo</h2>
<div style="margin-bottom: 16px;">
<ElSpace>
<ElButton @click="generateData(1000)">Generate 1,000 rows</ElButton>
<ElButton @click="generateData(5000)">Generate 5,000 rows</ElButton>
<ElButton @click="validateData">Validate data</ElButton>
<ElButton @click="showSelected">Show selected data</ElButton>
</ElSpace>
</div>
<ZTable
ref="tableRef"
:data="tableData"
:columns="columns"
:virtual="true"
:options="formOptions"
height="600px"
@update:data="handleDataUpdate"
@selection-change="handleSelectionChange"
/>
<div style="margin-top: 16px;">
<h3>Form component mapping:</h3>
<ul>
<li><strong>Name</strong>: input field</li>
<li><strong>Gender</strong>: select dropdown</li>
<li><strong>Age</strong>: input-number</li>
<li><strong>Department</strong>: multi-select</li>
<li><strong>Rating</strong>: radio group</li>
<li><strong>Skills</strong>: checkbox group</li>
<li><strong>Hire date</strong>: date picker</li>
<li><strong>Status</strong>: switch</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElButton, ElMessage, ElSpace } from 'element-plus'
const tableRef = ref()
const tableData = ref<any[]>([])
// Form option config
const formOptions = computed(() => ({
gender: [
{ label: 'Male', value: 'male' },
{ label: 'Female', value: 'female' },
{ label: 'Other', value: 'other' }
],
department: [
{ label: 'Engineering', value: 'tech' },
{ label: 'Product', value: 'product' },
{ label: 'Design', value: 'design' },
{ label: 'Operations', value: 'operation' },
{ label: 'Marketing', value: 'marketing' }
],
rating: [
{ label: 'Level A', value: 'A' },
{ label: 'Level B', value: 'B' },
{ label: 'Level C', value: 'C' },
{ label: 'Level D', value: 'D' }
],
skills: [
{ label: 'JavaScript', value: 'js' },
{ label: 'Vue', value: 'vue' },
{ label: 'React', value: 'react' },
{ label: 'Node.js', value: 'nodejs' },
{ label: 'Python', value: 'python' }
]
}))
// Table column config
const columns = ref([
{
type: 'selection',
label: 'Selection',
},
{
type: 'index',
label: 'Index',
index: 1,
},
{
prop: 'name',
label: 'Name',
component: 'input',
required: true,
fieldProps: {
placeholder: 'Please enter a name',
},
},
{
prop: 'gender',
label: 'Gender',
component: 'select',
fieldProps: {
placeholder: 'Please select a gender',
},
},
{
prop: 'age',
label: 'Age',
component: 'el-input-number',
fieldProps: {
min: 18,
max: 100,
placeholder: 'Please enter an age',
},
},
// {
// prop: 'department',
// label: 'Department',
// component: 'select',
// fieldProps: {
// placeholder: 'Please select departments',
// multiple: true,
// appendToBody: true,
// },
// },
{
prop: 'rating',
label: 'Rating',
component: 'radio',
fieldProps: {
placeholder: 'Please select a rating',
},
},
{
prop: 'skills',
label: 'Skills',
component: 'checkbox',
fieldProps: {
placeholder: 'Please select skills',
},
},
// {
// prop: 'joinDate',
// label: 'Hire date',
// component: 'el-date-picker',
// fieldProps: {
// type: 'date',
// placeholder: 'Please select a hire date',
// format: 'YYYY-MM-DD',
// valueFormat: 'YYYY-MM-DD',
// },
// },
{
prop: 'status',
label: 'Status',
component: 'el-switch',
fieldProps: {
activeText: 'Active',
inactiveText: 'Inactive',
},
},
])
// Generate mock data
function generateData(count: number) {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah']
const genders = ['male', 'female', 'other']
const departments = ['tech', 'product', 'design', 'operation', 'marketing']
const ratings = ['A', 'B', 'C', 'D']
const skills = ['js', 'vue', 'react', 'nodejs', 'python']
const data = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}_${i + 1}`,
gender: genders[i % genders.length],
age: Math.floor(Math.random() * 40) + 20,
department: [departments[i % departments.length], departments[(i + 1) % departments.length]],
rating: ratings[i % ratings.length],
skills: [skills[i % skills.length], skills[(i + 1) % skills.length]],
joinDate: `2020-${String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
status: Math.random() > 0.5,
__isEdit: false,
})
}
tableData.value = data
}
// Handle data updates
function handleDataUpdate(newData: any[]) {
tableData.value = newData
// console.log('Data updated:', newData)
}
// Validate data
function validateData() {
const invalidData = tableData.value.filter(item =>
!item.name || !item.gender || !item.age || !item.joinDate
)
if (invalidData.length > 0) {
ElMessage.error(`Found ${invalidData.length} invalid rows`)
// console.log('Invalid rows:', invalidData)
} else {
ElMessage.success('All rows passed validation')
}
}
// Show selected data
function showSelected() {
// eslint-disable-next-line no-console
console.log('Table data', tableData.value)
}
function handleSelectionChange(selection: any[]) {
// eslint-disable-next-line no-console
console.log('Selected rows', selection)
}
// Initialize data
generateData(1000)
</script>
<style scoped>
.virtual-form-components-demo {
padding: 20px;
}
h2 {
margin-bottom: 20px;
color: #333;
}
h3 {
margin-bottom: 10px;
color: #666;
}
ul {
padding-left: 20px;
margin: 0;
}
li {
margin-bottom: 5px;
color: #666;
}
</style>Column Show/Hide
Test dynamic show/hide of columns, supports functional and static configuration.
Features:
- Dynamic show/hide columns
- Functional config:
hide: () => boolean - Static config:
hide: boolean
Column visibility demo (2,000 rows)
<script lang="ts" setup>
import { ref } from 'vue'
const isHide = ref(false)
// Generate large dataset
function generateLargeData(count: number) {
const names = ['Steven', 'Helen', 'Nancy', 'Jack', 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
const genders = ['male', 'female']
const data = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
gender: genders[i % genders.length],
age: 18 + (i % 50),
time: `202${(i % 4) + 0}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
})
}
return data
}
const tableData = ref(generateLargeData(2000))
const columns = ref([
{
prop: 'id',
label: 'ID',
width: 200,
},
{
prop: 'name',
label: 'Name',
hide: () => isHide.value,
width: 200
},
{
prop: 'gender',
label: 'Gender',
hide: true, // Hidden by default
width: 200
},
{
prop: 'age',
label: 'Age',
width: 200
},
{
prop: 'time',
label: 'Date',
width: 200
},
])
function changeVisible() {
isHide.value = !isHide.value
}
function toggleGender() {
const genderColumn = columns.value.find(col => col.prop === 'gender')
if (genderColumn) {
genderColumn.hide = !genderColumn.hide
}
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h4>Column visibility demo (2,000 rows)</h4>
<el-space>
<el-button @click="changeVisible">
{{ isHide ? 'Show' : 'Hide' }} Name column
</el-button>
<el-button @click="toggleGender">
Toggle Gender column
</el-button>
</el-space>
</div>
<z-table
:data="tableData"
:columns="columns"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
height="400px"
border
stripe
/>
</div>
</template>Column Tooltip
Virtual table fully supports header tooltip, consistent with z-table’s regular table tooltip implementation.
Features:
- String tooltip:
tooltip: 'Content' - Function tooltip:
tooltip: (scope) => 'Dynamic content'with scope parameter - Object config:
tooltip: { content: 'Content', placement: 'top', effect: 'dark' }
Virtual Table – Column Tooltip Demo
<script lang="ts" setup>
import { ref } from 'vue'
// Generate mock data
function generateData() {
const departments = ['Engineering', 'Marketing', 'Product', 'Operations', 'Design']
const levels = ['Junior', 'Mid-level', 'Senior', 'Principal']
const data = []
for (let i = 1; i <= 2000; i++) {
data.push({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
department: departments[i % departments.length],
salary: 5000 + (i % 100) * 100,
level: levels[i % levels.length],
status: i % 3 === 0 ? 'Active' : 'Departed',
joinDate: new Date(2020 + (i % 4), (i % 12), (i % 28) + 1).toLocaleDateString(),
performance: Math.floor(Math.random() * 100),
})
}
return data
}
const tableData = ref(generateData())
// Aggregated stats
const totalUsers = tableData.value.length
const avgSalary = Math.round(tableData.value.reduce((sum, item) => sum + item.salary, 0) / totalUsers)
const departmentCount = new Set(tableData.value.map(item => item.department)).size
// Column config
const columns = [
{
type: 'index',
label: 'Index',
tooltip: 'Displays the row index starting from 1.'
},
{
prop: 'name',
label: 'User Name',
tooltip: 'The user’s display name for identification.'
},
{
prop: 'email',
label: 'Email Address',
tooltip: {
content: 'User email address used for contact and notifications.',
placement: 'bottom',
effect: 'light'
}
},
{
prop: 'age',
label: 'Age',
tooltip: (scope: any) => {
const minAge = Math.min(...tableData.value.map(item => item.age))
const maxAge = Math.max(...tableData.value.map(item => item.age))
return `Age range: ${minAge} - ${maxAge} (column index: ${scope.$index})`
}
},
{
prop: 'department',
label: 'Department',
tooltip: {
content: (scope: any) => {
return `There are ${departmentCount} departments (column: ${scope.column.prop}).`
},
placement: 'top-start',
effect: 'dark'
}
},
{
prop: 'salary',
label: 'Salary',
tooltip: (scope: any) => {
return `Average salary: ¥${avgSalary.toLocaleString()} (column index: ${scope.$index})`
}
},
{
prop: 'level',
label: 'Level',
tooltip: {
content: 'Employee seniority level: junior, mid-level, senior, or principal.',
placement: 'right',
showAfter: 500,
hideAfter: 100
}
},
{
prop: 'status',
label: 'Employment Status',
tooltip: {
content: (scope: any) => {
const activeCount = tableData.value.filter(item => item.status === 'Active').length
const inactiveCount = totalUsers - activeCount
return `Active: ${activeCount} · Departed: ${inactiveCount} (column: ${scope.column.label})`
},
placement: 'left',
effect: 'light'
}
},
{
prop: 'joinDate',
label: 'Hire Date',
tooltip: 'Date the employee joined the company.'
},
{
prop: 'performance',
label: 'Performance Score',
tooltip: {
content: (scope: any) => {
const avgPerformance = Math.round(
tableData.value.reduce((sum, item) => sum + item.performance, 0) / totalUsers
)
return `Average performance: ${avgPerformance} (column index: ${scope.$index})`
},
placement: 'bottom-end',
effect: 'dark',
showAfter: 300
}
}
]
</script>
<template>
<div>
<h3>Virtual Table – Column Tooltip Demo</h3>
<el-alert
title="✅ Column tooltip support"
type="success"
:closable="false"
style="margin-bottom: 16px;"
>
<template #default>
<p><strong>✅ String tooltips</strong>: Provide a literal string as tooltip content.</p>
<p><strong>✅ Function tooltips</strong>: Generate tooltip text dynamically with full scope access.</p>
<p><strong>✅ Object config</strong>: Configure placement, theme, delay, and more.</p>
<p><strong>✅ Scope payload</strong>: Functions receive <code>scope.column</code> and <code>scope.$index</code>.</p>
</template>
</el-alert>
<z-table
:data="tableData"
:columns="columns"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
height="500px"
border
stripe
row-key="id"
/>
</div>
</template>
<style scoped>
:deep(.el-collapse-item__content) {
padding: 12px !important;
}
</style>Watermark
Virtual table is fully compatible with z-table’s watermark feature.
Features:
- String watermark: pass a string as watermark content
- Object watermark: pass a config object to customize font, color, angle, spacing, etc.
Virtual Table – Watermark Support
Feature notes:
- String watermark: Supply a literal string.
- Object watermark: Provide a configuration object.
- Virtual scroll: Watermarks render above table rows.
- Performance: Watermarks do not impact scrolling performance.
Current configuration:
false
<template>
<div>
<h3>Virtual Table – Watermark Support</h3>
<div style="margin-bottom: 16px;">
<el-space>
<el-button @click="toggleWatermark">
{{ currentWatermarkType === 'none' ? 'Enable string watermark' : currentWatermarkType === 'string' ? 'Switch to object watermark' : 'Disable watermark' }}
</el-button>
<el-text type="info">Current mode: {{ watermarkModeText }}</el-text>
</el-space>
</div>
<el-alert
title="✅ Watermarks work with virtual tables"
type="success"
:closable="false"
style="margin-bottom: 16px;"
>
<template #default>
<p><strong>✅ String watermark</strong>: Provide a simple string as the watermark.</p>
<p><strong>✅ Object watermark</strong>: Configure font, color, angle, spacing, and more.</p>
<p><strong>✅ Dynamic switching</strong>: Toggle or update the watermark at runtime.</p>
<p><strong>✅ Virtual scroll compatible</strong>: Watermarks render correctly over virtualized content.</p>
</template>
</el-alert>
<z-table
:data="tableData"
:columns="columns"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
:watermark="currentWatermark"
height="500px"
border
stripe
row-key="id"
/>
<div style="margin-top: 16px;">
<h4>Feature notes:</h4>
<ul>
<li><strong>String watermark</strong>: Supply a literal string.</li>
<li><strong>Object watermark</strong>: Provide a configuration object.</li>
<li><strong>Virtual scroll</strong>: Watermarks render above table rows.</li>
<li><strong>Performance</strong>: Watermarks do not impact scrolling performance.</li>
</ul>
</div>
<div style="margin-top: 16px;">
<h4>Current configuration:</h4>
<pre style="padding: 12px; font-size: 12px; background: #f5f7fa; border-radius: 4px;">{{ JSON.stringify(currentWatermark, null, 2) }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
// Generate mock data
function generateLargeData(count: number) {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing', 'Sales', 'HR', 'Finance']
const data = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]} ${Math.floor(i / names.length) + 1}`,
email: `user${i + 1}@company.com`,
age: 22 + (i % 40),
department: departments[i % departments.length],
salary: 5000 + Math.floor(Math.random() * 10000),
status: i % 3 === 0 ? 'active' : 'inactive',
joinDate: `2023-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`
})
}
return data
}
const tableData = ref(generateLargeData(2000))
const columns = ref([
{
type: 'selection',
},
{
type: 'index',
label: 'Index',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'age',
label: 'Age',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'salary',
label: 'Salary',
render: ({ row }: any) => `¥${row.salary.toLocaleString()}`
},
{
prop: 'status',
label: 'Status',
render: ({ row }: any) => row.status === 'active' ? 'Active' : 'Inactive'
},
{
prop: 'joinDate',
label: 'Hire Date',
}
])
// Watermark state
const currentWatermarkType = ref<'none' | 'string' | 'object'>('none')
const currentWatermark = computed(() => {
switch (currentWatermarkType.value) {
case 'string':
return 'ideaz-element virtual table watermark'
case 'object':
return {
content: 'ideaz-element',
fontColor: 'rgba(0, 0, 0, 0.15)',
rotate: -20,
gap: [100, 100],
offset: [50, 50]
}
default:
return false
}
})
const watermarkModeText = computed(() => {
switch (currentWatermarkType.value) {
case 'string':
return 'String watermark'
case 'object':
return 'Object watermark'
default:
return 'No watermark'
}
})
function toggleWatermark() {
if (currentWatermarkType.value === 'none') {
currentWatermarkType.value = 'string'
} else if (currentWatermarkType.value === 'string') {
currentWatermarkType.value = 'object'
} else {
currentWatermarkType.value = 'none'
}
}
</script>Footer
Virtual table fully supports Element Plus TableV2 footer features.
Features:
- Footer slot: customize footer content via
#footer - Height config: configure footer height via
virtual.footerHeight
Virtual Table Footer Demo
The virtual table exposes a footer slot so you can display aggregated statistics or any custom content.
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { computed, h, ref } from 'vue'
interface RowData {
id: number
name: string
department: string
salary: number
bonus: number
total: number
status: string
}
// Generate mock data
function generateData(count: number): RowData[] {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah', 'Ian', 'Julia']
const departments = ['Engineering', 'Product', 'Design', 'Operations', 'Marketing']
const statuses = ['Active', 'Departed', 'Probation']
const data: RowData[] = []
for (let i = 0; i < count; i++) {
const salary = Math.floor(Math.random() * 30000) + 8000
const bonus = Math.floor(Math.random() * 5000) + 1000
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
department: departments[i % departments.length],
salary,
bonus,
total: salary + bonus,
status: statuses[i % statuses.length],
})
}
return data
}
const tableData = ref<RowData[]>(generateData(1000))
const footerHeight = ref(80)
const showFooter = ref(true)
const columns = ref([
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'salary',
label: 'Base Salary',
render: ({ row }: any) => `¥${row.salary.toLocaleString()}`,
},
{
prop: 'bonus',
label: 'Bonus',
render: ({ row }: any) => `¥${row.bonus.toLocaleString()}`,
},
{
prop: 'total',
label: 'Total Income',
render: ({ row }: any) => h('span', {
style: { color: '#67c23a', fontWeight: 'bold' }
}, `¥${row.total.toLocaleString()}`),
},
{
prop: 'status',
label: 'Status',
render: ({ row }: any) => h('span', {
style: {
color: row.status === 'Active' ? '#67c23a' : row.status === 'Probation' ? '#e6a23c' : '#f56c6c',
fontWeight: 'bold'
}
}, row.status),
},
])
// Aggregate stats
const statistics = computed(() => {
const validData = tableData.value.filter(item => item.status !== 'Departed')
return {
totalEmployees: validData.length,
totalSalary: validData.reduce((sum, item) => sum + item.salary, 0),
totalBonus: validData.reduce((sum, item) => sum + item.bonus, 0),
totalIncome: validData.reduce((sum, item) => sum + item.total, 0),
averageSalary: Math.round(validData.reduce((sum, item) => sum + item.salary, 0) / validData.length),
}
})
function resetData() {
tableData.value = generateData(1000)
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h3>Virtual Table Footer Demo</h3>
<p style="margin: 8px 0; color: #666;">
The virtual table exposes a footer slot so you can display aggregated statistics or any custom content.
</p>
<el-space wrap>
<el-button @click="showFooter = !showFooter" :type="showFooter ? 'danger' : 'primary'">
{{ showFooter ? 'Hide' : 'Show' }} footer
</el-button>
<el-input-number
v-model="footerHeight"
:min="50"
:max="200"
:step="10"
controls-position="right"
style="width: 150px;"
/>
<span style="color: #666;">Footer height: {{ footerHeight }}px</span>
<el-button @click="resetData" type="success">
Regenerate data
</el-button>
</el-space>
</div>
<z-table
v-model:data="tableData"
:columns="columns"
:virtual="{
enabled: true,
itemHeight: 48,
threshold: 50,
footerHeight: showFooter ? footerHeight : 0
}"
height="500px"
border
stripe
>
<!-- Footer slot - statistics -->
<template #footer v-if="showFooter">
<div class="table-footer">
<div class="footer-content">
<div class="statistics-section">
<div class="stat-item">
<div class="stat-info">
<div class="stat-value">{{ statistics.totalEmployees }}</div>
<div class="stat-label">Active employees</div>
</div>
</div>
<div class="divider">|</div>
<div class="stat-item">
<div class="stat-info">
<div class="stat-value">¥{{ statistics.totalSalary.toLocaleString() }}</div>
<div class="stat-label">Total base salary</div>
</div>
</div>
<div class="divider">|</div>
<div class="stat-item">
<div class="stat-info">
<div class="stat-value">¥{{ statistics.totalBonus.toLocaleString() }}</div>
<div class="stat-label">Total bonus</div>
</div>
</div>
<div class="divider">|</div>
<div class="stat-item">
<div class="stat-info">
<div class="stat-value">¥{{ statistics.totalIncome.toLocaleString() }}</div>
<div class="stat-label">Total income</div>
</div>
</div>
<div class="divider">|</div>
<div class="stat-item">
<div class="stat-info">
<div class="stat-value">¥{{ statistics.averageSalary.toLocaleString() }}</div>
<div class="stat-label">Average salary</div>
</div>
</div>
</div>
</div>
</div>
</template>
</z-table>
</div>
</template>
<style scoped>
.table-footer {
display: flex;
align-items: center;
height: 100%;
padding: 0 20px;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.footer-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.statistics-section {
display: flex;
gap: 16px;
align-items: center;
}
.stat-item {
display: flex;
gap: 8px;
align-items: center;
}
.divider {
margin: 0 8px;
font-size: 16px;
color: rgba(255, 255, 255, 60%);
}
.stat-info {
text-align: left;
}
.stat-value {
font-size: 16px;
font-weight: bold;
line-height: 1;
}
.stat-label {
margin-top: 2px;
font-size: 12px;
opacity: 80%;
}
.action-section {
display: flex;
gap: 8px;
}
</style>Custom Column Rendering
Validate custom rendering of column content; supports both render function and slot.
Virtual Table – Column Customization
<template>
<div>
<h3>Virtual Table – Column Customization</h3>
<div style="margin-bottom: 16px;">
<el-space>
<el-button @click="toggleCustomMode">
{{ useSlotMode ? 'Switch to Render mode' : 'Switch to Slot mode' }}
</el-button>
<el-text type="info">Current mode: {{ useSlotMode ? 'Slot mode' : 'Render function' }}</el-text>
</el-space>
</div>
<el-alert
title="✅ Column customization"
type="success"
:closable="false"
style="margin-bottom: 16px;"
>
<template #default>
<p><strong>✅ Render function</strong>: Customize column content via a render function</p>
<p><strong>✅ Slot mode</strong>: Customize column content via slots</p>
<p><strong>✅ Parameter passing</strong>: Full scope payload including row, column, cellData, $index, etc.</p>
<p><strong>✅ Event support</strong>: Bind events and interactions inside custom content</p>
<p><strong>✅ Component support</strong>: Works with any Element Plus component</p>
</template>
</el-alert>
<z-table
ref="tableRef"
:data="tableData"
:columns="columns"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
height="500px"
border
stripe
row-key="id"
>
<!-- Slot-based column customization -->
<template #avatar-slot="scope">
<div style="display: flex; gap: 8px; align-items: center;">
<el-avatar :size="32" :src="scope.row.avatar" />
<div>
<div style="font-weight: bold;">{{ scope.row.name }}</div>
<div style="font-size: 12px; color: #909399;">{{ scope.row.email }}</div>
</div>
</div>
</template>
<template #salary-slot="scope">
<div @click="handleSalaryClick(scope)" style="cursor: pointer;">
<el-tag
:type="scope.row.salary > 8000 ? 'success' : scope.row.salary > 6000 ? 'warning' : 'danger'"
effect="dark"
>
<el-icon style="margin-right: 4px;"><Money /></el-icon>
¥{{ scope.row.salary.toLocaleString() }}
</el-tag>
</div>
</template>
<template #status-slot="scope">
<el-switch
v-model="scope.row.status"
active-value="active"
inactive-value="inactive"
active-text="Active"
inactive-text="Inactive"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
@change="handleStatusChange(scope)"
/>
</template>
<template #progress-slot="scope">
<div style="display: flex; gap: 8px; align-items: center;">
<el-progress
:percentage="scope.row.progress"
:stroke-width="8"
:show-text="false"
style="min-width: 50px"
/>
<span style="min-width: 35px; font-size: 12px; color: #606266;">
{{ scope.row.progress }}%
</span>
</div>
</template>
<template #actions-slot="scope">
<el-space size="small">
<el-button
size="small"
type="primary"
:icon="Edit"
@click="handleEdit(scope)"
>
Edit
</el-button>
<el-button
size="small"
type="success"
:icon="View"
@click="handleView(scope)"
>
View
</el-button>
<el-popconfirm
title="Are you sure you want to delete?"
@confirm="handleDelete(scope)"
>
<template #reference>
<el-button
size="small"
type="danger"
:icon="Delete"
>
Delete
</el-button>
</template>
</el-popconfirm>
</el-space>
</template>
</z-table>
<!-- Parameter logs -->
<div style="margin-top: 16px;">
<el-collapse v-model="activeCollapse">
<el-collapse-item title="Latest parameter logs" name="1">
<div style="max-height: 200px; padding: 12px; border-radius: 4px; background: #f5f7fa; overflow-y: auto;">
<div v-for="(log, index) in paramLogs" :key="index" style="margin-bottom: 8px; font-family: monospace; font-size: 12px;">
<span style="color: #909399;">[{{ log.timestamp }}]</span>
<span style="font-weight: bold; color: #409eff;">{{ log.type }}:</span>
<span style="color: #606266;">{{ log.message }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, ref } from 'vue'
import { Delete, Edit, Money, View } from '@element-plus/icons-vue'
import { ElAvatar, ElIcon, ElSwitch, ElProgress, ElSpace, ElButton, ElPopconfirm, ElTag } from 'element-plus'
// Generate mock data
function generateData() {
const departments = ['Engineering', 'Marketing', 'Product', 'Operations', 'Design']
const data = []
for (let i = 1; i <= 2000; i++) {
data.push({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
department: departments[i % departments.length],
salary: 5000 + (i % 100) * 100,
status: i % 3 === 0 ? 'active' : 'inactive',
progress: Math.floor(Math.random() * 100),
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=user${i}`,
})
}
return data
}
const tableData = ref(generateData())
const tableRef = ref()
const useSlotMode = ref(true)
const activeCollapse = ref(['1'])
const paramLogs = ref<any[]>([])
// Append log entry
function addLog(type: string, message: string, params?: any) {
const timestamp = new Date().toLocaleTimeString()
paramLogs.value.unshift({
timestamp,
type,
message,
params
})
// Keep only the latest 20 logs
if (paramLogs.value.length > 20) {
paramLogs.value = paramLogs.value.slice(0, 20)
}
// eslint-disable-next-line no-console
console.log(`[${type}]`, message, params)
}
// Column config
const columns = computed(() => [
{
type: 'index',
label: 'Index',
},
{
prop: 'name',
label: 'User Info',
// Choose render or slot mode
...(useSlotMode.value ? {
slot: 'avatar-slot'
} : {
render: (scope: any) => {
return h('div', {
style: { display: 'flex', gap: '8px', alignItems: 'center' }
}, [
h(ElAvatar, {
size: 32,
src: scope.row.avatar
}),
h('div', {}, [
h('div', { style: { fontWeight: 'bold' } }, scope.row.name),
h('div', {
style: { fontSize: '12px', color: '#909399' }
}, scope.row.email)
])
])
}
})
},
{
prop: 'department',
label: 'Department',
},
{
prop: 'age',
label: 'Age',
},
{
prop: 'salary',
label: 'Salary',
...(useSlotMode.value ? {
slot: 'salary-slot'
} : {
render: (scope: any) => {
const getType = (salary: number) => {
if (salary > 8000) return 'success'
if (salary > 6000) return 'warning'
return 'danger'
}
return h('div', {
onClick: () => handleSalaryClick(scope),
style: { cursor: 'pointer' }
}, [
h(ElTag, {
type: getType(scope.row.salary),
effect: 'dark'
}, {
default: () => [
h(ElIcon, { style: { marginRight: '4px' } }, {
default: () => h(Money)
}),
`¥${scope.row.salary.toLocaleString()}`
]
})
])
}
})
},
{
prop: 'status',
label: 'Status',
...(useSlotMode.value ? {
slot: 'status-slot'
} : {
render: (scope: any) => {
return h(ElSwitch, {
modelValue: scope.row.status,
activeValue: 'active',
inactiveValue: 'inactive',
activeText: 'Active',
inactiveText: 'Inactive',
style: '--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949',
'onUpdate:modelValue': (value: string) => {
scope.row.status = value
handleStatusChange(scope)
}
})
}
})
},
{
prop: 'progress',
label: 'Project Progress',
...(useSlotMode.value ? {
slot: 'progress-slot'
} : {
render: (scope: any) => {
return h('div', {
style: { display: 'flex', gap: '8px', alignItems: 'center' }
}, [
h(ElProgress, {
percentage: scope.row.progress,
strokeWidth: 8,
showText: false,
style: { minWidth: '50px' }
}),
h('span', {
style: { minWidth: '35px', fontSize: '12px', color: '#606266' }
}, `${scope.row.progress}%`)
])
}
})
},
{
prop: 'actions',
label: 'Actions',
fixed: 'right',
...(useSlotMode.value ? {
slot: 'actions-slot'
} : {
render: (scope: any) => {
return h(ElSpace, { size: 'small' }, {
default: () => [
h(ElButton, {
size: 'small',
type: 'primary',
icon: Edit,
onClick: () => handleEdit(scope)
}, {
default: () => 'Edit'
}),
h(ElButton, {
size: 'small',
type: 'success',
icon: View,
onClick: () => handleView(scope)
}, {
default: () => 'View'
}),
h(ElPopconfirm, {
title: 'Are you sure you want to delete?',
onConfirm: () => handleDelete(scope)
}, {
reference: () => h(ElButton, {
size: 'small',
type: 'danger',
icon: Delete
}, {
default: () => 'Delete'
})
})
]
})
}
})
}
])
// Event handlers
function handleSalaryClick(scope: any) {
addLog('Slot event', `Clicked salary - row ${scope.$index}, user: ${scope.row.name}, salary: ${scope.row.salary}`, scope)
}
function handleStatusChange(scope: any) {
addLog('Slot event', `Status changed - row ${scope.$index}, user: ${scope.row.name}, new status: ${scope.row.status}`, scope)
}
function handleEdit(scope: any) {
addLog('Slot event', `Edit user - row ${scope.$index}, user: ${scope.row.name}`, scope)
}
function handleView(scope: any) {
addLog('Slot event', `View user - row ${scope.$index}, user: ${scope.row.name}`, scope)
}
function handleDelete(scope: any) {
addLog('Slot event', `Delete user - row ${scope.$index}, user: ${scope.row.name}`, scope)
}
function toggleCustomMode() {
useSlotMode.value = !useSlotMode.value
addLog('Mode toggle', `Switched to ${useSlotMode.value ? 'Slot' : 'Render'} mode`)
}
</script>
<style scoped>
:deep(.el-collapse-item__content) {
padding: 12px !important;
}
</style>Custom Header
Test custom header features including icons, badges, dropdowns, and other interactive elements.
Virtual Table – Header Customization
<template>
<div>
<h3>Virtual Table – Header Customization</h3>
<div style="margin-bottom: 16px;">
<el-space>
<el-button @click="toggleCustomMode">
{{ useSlotMode ? 'Switch to Render mode' : 'Switch to Slot mode' }}
</el-button>
<el-text type="info">Current mode: {{ useSlotMode ? 'Slot mode' : 'Render function' }}</el-text>
</el-space>
</div>
<el-alert
title="✅ Header customization"
type="success"
:closable="false"
style="margin-bottom: 16px;"
>
<template #default>
<p><strong>✅ Render function</strong>: Customize headers via render functions</p>
<p><strong>✅ Slot mode</strong>: Customize headers via slots</p>
<p><strong>✅ Icon support</strong>: Icons, hints, badges, and other rich content</p>
<p><strong>✅ Interaction support</strong>: Click handlers and interactive elements in headers</p>
<p><strong>✅ Style customization</strong>: Full control over layout and styling</p>
</template>
</el-alert>
<z-table
ref="tableRef"
:data="tableData"
:columns="columns"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
height="500px"
border
stripe
row-key="id"
>
<!-- Slot-based header customization -->
<template #user-header>
<div style="display: flex; gap: 6px; align-items: center;">
<el-icon color="#409eff" size="16"><User /></el-icon>
<span>User info</span>
<el-tooltip content="Includes user name, email, and other basic information" placement="top">
<el-icon color="#909399" size="14" style="cursor: help;"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<template #department-header>
<div style="display: flex; gap: 6px; align-items: center;">
<el-icon color="#67c23a" size="16"><OfficeBuilding /></el-icon>
<span>Department info</span>
<el-badge :value="departmentCount" type="primary" :max="99">
<span></span>
</el-badge>
</div>
</template>
<template #salary-header>
<div
style="display: flex; gap: 6px; align-items: center; cursor: pointer;"
@click="handleSalaryHeaderClick"
>
<el-icon color="#f56c6c" size="16"><Money /></el-icon>
<span>Salary</span>
<el-tag size="small" type="warning">¥{{ avgSalary }}</el-tag>
</div>
</template>
<template #status-header>
<div style="display: flex; gap: 6px; align-items: center;">
<el-icon color="#909399" size="16"><SwitchButton /></el-icon>
<span>Employment status</span>
<div style="display: flex; gap: 4px;">
<el-tag size="small" type="success">{{ activeCount }} active</el-tag>
<el-tag size="small" type="info">{{ inactiveCount }} inactive</el-tag>
</div>
</div>
</template>
<template #actions-header>
<div style="display: flex; gap: 6px; align-items: center;">
<el-icon color="#e6a23c" size="16"><Setting /></el-icon>
<span>Actions</span>
<el-dropdown @command="handleHeaderCommand">
<el-icon style="cursor: pointer;" color="#409eff"><More /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="export">Export data</el-dropdown-item>
<el-dropdown-item command="import">Import data</el-dropdown-item>
<el-dropdown-item command="settings">Column settings</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</z-table>
<!-- Parameter logs -->
<div style="margin-top: 16px;">
<el-collapse v-model="activeCollapse">
<el-collapse-item title="Latest parameter logs" name="1">
<div style="max-height: 200px; padding: 12px; overflow-y: auto; background: #f5f7fa; border-radius: 4px;">
<div v-for="(log, index) in paramLogs" :key="index" style="margin-bottom: 8px; font-family: monospace; font-size: 12px;">
<span style="color: #909399;">[{{ log.timestamp }}]</span>
<span style="font-weight: bold; color: #409eff;">{{ log.type }}:</span>
<span style="color: #606266;">{{ log.message }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, ref } from 'vue'
import {
Money,
More,
OfficeBuilding,
QuestionFilled,
Setting,
SwitchButton,
User
} from '@element-plus/icons-vue'
import { ElBadge, ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElTag, ElTooltip } from 'element-plus'
// Generate mock data
function generateData() {
const departments = ['Engineering', 'Marketing', 'Product', 'Operations', 'Design']
const data = []
for (let i = 1; i <= 2000; i++) {
data.push({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
age: 20 + (i % 50),
department: departments[i % departments.length],
salary: 5000 + (i % 100) * 100,
status: i % 3 === 0 ? 'active' : 'inactive',
})
}
return data
}
const tableData = ref(generateData())
const tableRef = ref()
const useSlotMode = ref(true)
const activeCollapse = ref(['1'])
const paramLogs = ref<any[]>([])
// Derived statistics
const departmentCount = computed(() => {
const depts = new Set(tableData.value.map(item => item.department))
return depts.size
})
const avgSalary = computed(() => {
const total = tableData.value.reduce((sum, item) => sum + item.salary, 0)
return Math.round(total / tableData.value.length)
})
const activeCount = computed(() => {
return tableData.value.filter(item => item.status === 'active').length
})
const inactiveCount = computed(() => {
return tableData.value.filter(item => item.status === 'inactive').length
})
// Append log entry
function addLog(type: string, message: string, params?: any) {
const timestamp = new Date().toLocaleTimeString()
paramLogs.value.unshift({
timestamp,
type,
message,
params
})
// Keep only the latest 20 logs
if (paramLogs.value.length > 20) {
paramLogs.value = paramLogs.value.slice(0, 20)
}
// eslint-disable-next-line no-console
console.log(`[${type}]`, message, params)
}
// Column config
const columns = computed(() => [
{
type: 'index',
label: 'Index',
},
{
prop: 'name',
// Choose header behavior based on mode
...(useSlotMode.value ? {
label: 'user-header'
} : {
label: () => {
return h('div', {
style: { display: 'flex', gap: '6px', alignItems: 'center' }
}, [
h(ElIcon, { color: '#409eff', size: 16 }, {
default: () => h(User)
}),
h('span', null, 'User info'),
h(ElTooltip, {
content: 'Includes user name, email, and other core details',
placement: 'top'
}, {
default: () => h(ElIcon, {
color: '#909399',
size: 14,
style: { cursor: 'help' }
}, {
default: () => h(QuestionFilled)
})
})
])
}
})
},
{
prop: 'email',
label: 'Email',
},
{
prop: 'age',
label: 'Age',
},
{
prop: 'department',
...(useSlotMode.value ? {
label: 'department-header'
} : {
label: () => {
return h('div', {
style: { display: 'flex', gap: '6px', alignItems: 'center' }
}, [
h(ElIcon, { color: '#67c23a', size: 16 }, {
default: () => h(OfficeBuilding)
}),
h('span', null, 'Department info'),
h(ElBadge, {
value: departmentCount.value,
type: 'primary',
max: 99
}, {
default: () => h('span')
})
])
}
})
},
{
prop: 'salary',
...(useSlotMode.value ? {
label: 'salary-header'
} : {
label: () => {
return h('div', {
style: { display: 'flex', gap: '6px', alignItems: 'center', cursor: 'pointer' },
onClick: handleSalaryHeaderClick
}, [
h(ElIcon, { color: '#f56c6c', size: 16 }, {
default: () => h(Money)
}),
h('span', null, 'Salary'),
h(ElTag, {
size: 'small',
type: 'warning'
}, {
default: () => `¥${avgSalary.value}`
})
])
}
})
},
{
prop: 'status',
...(useSlotMode.value ? {
label: 'status-header'
} : {
label: () => {
return h('div', {
style: { display: 'flex', gap: '6px', alignItems: 'center' }
}, [
h(ElIcon, { color: '#909399', size: 16 }, {
default: () => h(SwitchButton)
}),
h('span', null, 'Employment status'),
h('div', {
style: { display: 'flex', gap: '4px' }
}, [
h(ElTag, {
size: 'small',
type: 'success'
}, {
default: () => `${activeCount.value} active`
}),
h(ElTag, {
size: 'small',
type: 'info'
}, {
default: () => `${inactiveCount.value} inactive`
})
])
])
}
})
},
{
prop: 'actions',
fixed: 'right',
...(useSlotMode.value ? {
label: 'actions-header'
} : {
label: () => {
return h('div', {
style: { display: 'flex', gap: '6px', alignItems: 'center' }
}, [
h(ElIcon, { color: '#e6a23c', size: 16 }, {
default: () => h(Setting)
}),
h('span', null, 'Actions'),
h(ElDropdown, {
onCommand: handleHeaderCommand
}, {
default: () => h(ElIcon, {
style: { cursor: 'pointer' },
color: '#409eff'
}, {
default: () => h(More)
}),
dropdown: () => h(ElDropdownMenu, null, () => [
h(ElDropdownItem, { command: 'export' }, {
default: () => 'Export data'
}),
h(ElDropdownItem, { command: 'import' }, {
default: () => 'Import data'
}),
h(ElDropdownItem, { command: 'settings' }, {
default: () => 'Column settings'
})
])
})
])
}
})
}
])
// Event handlers
function handleSalaryHeaderClick() {
addLog('Header event', `Clicked salary header – average salary: ${avgSalary.value}`)
}
function handleHeaderCommand(command: string) {
addLog('Header event', `Actions header dropdown – command: ${command}`)
}
function toggleCustomMode() {
useSlotMode.value = !useSlotMode.value
addLog('Mode toggle', `Switched to ${useSlotMode.value ? 'Slot' : 'Render'} mode`)
}
</script>
<style scoped>
:deep(.el-collapse-item__content) {
padding: 12px !important;
}
</style>Methods and Events
Test table method calls and event handling.
Virtual table method tests
Virtual-scroll specific methods
<!-- eslint-disable no-console -->
<script lang="ts" setup>
import { ref } from 'vue'
interface RowData {
id: number
name: string
gender: string
age: number
time: string
}
// Generate a large dataset
function generateLargeData(count: number): RowData[] {
const names = ['Steven', 'Helen', 'Nancy', 'Jack', 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
const genders = ['male', 'female']
const data: RowData[] = []
for (let i = 0; i < count; i++) {
data.push({
id: i + 1,
name: `${names[i % names.length]}-${i + 1}`,
gender: genders[i % genders.length],
age: 18 + (i % 50),
time: `202${(i % 4) + 0}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
})
}
return data
}
const tableData = ref<RowData[]>(generateLargeData(1500))
const tableRef = ref()
const columns = ref([
{
type: 'selection',
},
{
type: 'index',
},
{
prop: 'id',
label: 'ID',
},
{
prop: 'name',
label: 'Name',
},
{
prop: 'gender',
label: 'Gender',
},
{
prop: 'age',
label: 'Age',
sortable: true,
},
{
prop: 'time',
label: 'Date',
},
])
// Table method demos
function setCurrentRow() {
const row = tableData.value[10]
tableRef.value?.setCurrentRow(row)
console.log('Set current row:', row)
}
function toggleRowSelection() {
const row = tableData.value[5]
tableRef.value?.toggleRowSelection(row, true)
console.log('Toggle row selection:', row)
}
function clearSelection() {
tableRef.value?.clearSelection()
console.log('Clear selection')
}
function toggleAllSelection() {
tableRef.value?.toggleAllSelection()
console.log('Toggle select all')
}
function clearSort() {
tableRef.value?.clearSort()
console.log('Clear sort')
}
function scrollToTop() {
tableRef.value?.scrollTo({ scrollTop: 0 })
console.log('Scroll to top')
}
function scrollToBottom() {
tableRef.value?.scrollTo({ scrollTop: 999999 })
console.log('Scroll to bottom')
}
function scrollToRow() {
const index = 500
tableRef.value?.scrollToRow(index)
console.log(`Scroll to row ${index + 1}`)
}
// Event handlers
function handleCurrentChange(currentRow: RowData, oldCurrentRow: RowData) {
console.log('Current row changed:', currentRow, oldCurrentRow)
}
function handleSelectionChange(selection: RowData[]) {
console.log('Selection changed:', selection.length, 'rows')
}
function handleSortChange({ column, prop, order }: any) {
console.log('Sort changed:', { column, prop, order })
}
</script>
<template>
<div>
<div style="margin-bottom: 16px;">
<h4>Virtual table method tests</h4>
<el-button @click="setCurrentRow">Set current row (row 11)</el-button>
<el-button @click="toggleRowSelection">Select row 6</el-button>
<el-button @click="clearSelection">Clear selection</el-button>
<el-button @click="toggleAllSelection">Toggle select all</el-button>
<el-button @click="clearSort">Clear sorting</el-button>
</div>
<div style="margin-bottom: 16px;">
<h4>Virtual-scroll specific methods</h4>
<el-button @click="scrollToTop">Scroll to top</el-button>
<el-button @click="scrollToBottom">Scroll to bottom</el-button>
<el-button @click="scrollToRow">Scroll to row 501</el-button>
</div>
<z-table
ref="tableRef"
v-model:data="tableData"
:columns="columns"
:virtual="{ enabled: true, itemHeight: 48, threshold: 100 }"
height="500px"
row-key="id"
highlight-current-row
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
/>
</div>
</template>📖 Configuration Reference
Options
| Prop | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Whether to enable virtual scrolling |
itemHeight | number | 48 | Fixed row height (px) |
estimatedRowHeight | number | 48 | Estimated row height for dynamic height |
threshold | number | 100 | Auto-enable when data exceeds this value |
footerHeight | number | 50 | Footer height (px) |
API Methods
Virtual-scroll-specific methods
// Scroll to a specific position
tableRef.value.scrollTo({
scrollTop: 1000,
scrollLeft: 200
})
// Scroll to a specific row
tableRef.value.scrollToRow(500, 'center')General table methods
All original table methods remain available in virtual mode:
// Set current row
tableRef.value.setCurrentRow(row)
// Toggle row selection
tableRef.value.toggleRowSelection(row, true)
// Clear selection
tableRef.value.clearSelection()
// Sort
tableRef.value.sort('column', 'ascending')Usage Recommendations
🎯 When to use virtual scroll
- Data volume > 1000 rows
- Need to display all data at once
- Users need to browse and locate quickly
- Performance-sensitive scenarios
⚠️ Notes
Fixed height requirement:
- Must set the
heightprop - Dynamic row height is not supported
- Must set the
Performance tips:
- Enable when data volume > 1000 rows
- Avoid overly complex components in columns
- No need to manually specify column width; handled by system