Skip to content

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

vue
<!-- 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

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

vue
<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-keys supports two-way binding of expand state
  • Events: @expand-change, @row-expand (native TableV2 events)
  • Methods: toggleRowExpansion
  • Slots: #expand slot 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 index prop
  • Function index: Supports function-type index for 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>

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

Current mode: Slot mode
<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

PropTypeDefaultDescription
enabledbooleanfalseWhether to enable virtual scrolling
itemHeightnumber48Fixed row height (px)
estimatedRowHeightnumber48Estimated row height for dynamic height
thresholdnumber100Auto-enable when data exceeds this value
footerHeightnumber50Footer height (px)

API Methods

Virtual-scroll-specific methods

javascript
// 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:

javascript
// 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

  1. Fixed height requirement:

    • Must set the height prop
    • Dynamic row height is not supported
  2. Performance tips:

    • Enable when data volume > 1000 rows
    • Avoid overly complex components in columns
    • No need to manually specify column width; handled by system

Released under the MIT License.