Skip to content
18 changes: 18 additions & 0 deletions docs/content/docs/2.components/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,24 @@ class: '!p-0'
A height constraint is required on the table for virtualization to work properly (e.g., `class="h-[400px]"`).
::

### Dynamic row heights :badge{label="Soon" class="align-text-top"}

`estimateSize` is only the initial guess β€” the virtualizer measures every rendered row and updates its internal size map as you scroll. For rows with variable content (expandable rows, rich text, images), pass a `measureElement` callback to read the actual rendered height:

```vue
<UTable
:data="rows"
:columns="columns"
:virtualize="{
estimateSize: () => 65,
measureElement: (el) => el.getBoundingClientRect().height
}"
class="h-[500px]"
/>
```

When a row is expanded via `row.toggleExpanded()`, the expansion row's height is automatically added to the measurement β€” you don't need to walk siblings yourself.

### With tree data

You can use the `get-sub-rows` prop to display hierarchical (tree) data in the table.
Expand Down
95 changes: 89 additions & 6 deletions src/runtime/components/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
meta?: TableMeta<T>
/**
* Enable virtualization for large datasets.
*
* Pass a `measureElement` function to opt into dynamic row heights. The value returned by
* your `measureElement` is used as the height of the main row; when `row.getIsExpanded()`
* is `true`, the immediate next-sibling `<tr>`'s height is **added on top of** that value
* automatically, so your callback should measure the main row only. If your custom
* `measureElement` already includes the expanded region (e.g. by measuring a wrapper),
* return only the collapsed-row height to avoid double-counting.
*
* TanStack Virtual's `ResizeObserver` is attached to the main `<tr>` only, and this
* component re-measures rows whenever the TanStack expanded state toggles. It does **not**
* observe size changes *inside* the expansion sibling β€” late-loading images, async content,
* or nested toggles that resize the expansion region after mount won't trigger an automatic
* re-measure. Call `virtualizer.measure()` (or `virtualizer.measureElement(row)`) yourself
* in those cases.
*
* Note: row pinning is not supported when virtualization is enabled.
* @see https://tanstack.com/virtual/latest/docs/api/virtualizer#options
* @defaultValue false
Expand All @@ -110,7 +125,8 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
*/
overscan?: number
/**
* Estimated size (in px) of each item, or a function that returns the size for a given index
* Estimated size (in px) of each item, or a function that returns the size for a given index.
* Used as the initial estimate before the virtualizer measures actual row heights.
* @defaultValue 65
*/
estimateSize?: number | ((index: number) => number)
Expand Down Expand Up @@ -224,7 +240,7 @@ export type TableSlots<T extends TableData = TableData> = {
</script>

<script setup lang="ts" generic="T extends TableData">
import { computed, useTemplateRef, watch, toRef } from 'vue'
import { computed, nextTick, useTemplateRef, watch, toRef } from 'vue'
import { Primitive } from 'reka-ui'
import { upperFirst } from 'scule'
import { defu } from 'defu'
Expand Down Expand Up @@ -290,7 +306,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.table || {})
}))

const [DefineTableTemplate, ReuseTableTemplate] = createReusableTemplate()
const [DefineRowTemplate, ReuseRowTemplate] = createReusableTemplate<{ row: TableRow<T>, style?: Record<string, string> }>({
const [DefineRowTemplate, ReuseRowTemplate] = createReusableTemplate<{ row: TableRow<T>, style?: Record<string, string>, index?: number }>({
props: {
row: {
type: Object,
Expand All @@ -299,6 +315,11 @@ const [DefineRowTemplate, ReuseRowTemplate] = createReusableTemplate<{ row: Tabl
style: {
type: Object,
required: false
},
index: {
type: Number,
required: false,
default: undefined
}
}
})
Expand Down Expand Up @@ -438,6 +459,17 @@ const virtualizer = !!props.virtualize && useVirtualizer({
estimateSize: (index: number) => {
const estimate = virtualizerProps.value.estimateSize
return typeof estimate === 'function' ? estimate(index) : estimate
},
measureElement: (el, entry, instance) => {
const userMeasure = virtualizerProps.value.measureElement
const base = userMeasure
? userMeasure(el, entry, instance)
: Math.round(entry?.borderBoxSize?.[0]?.blockSize ?? (el as HTMLElement).offsetHeight)
if ((el as Element).getAttribute('data-expanded') === 'true') {
const next = (el as Element).nextElementSibling as HTMLElement | null
if (next && next.tagName === 'TR') return base + next.offsetHeight
}
return base
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

Expand All @@ -450,6 +482,55 @@ const virtualPaddingBottom = computed(() => {
return virtualizer.value.getTotalSize() - (virtualItems.value[virtualItems.value.length - 1]?.end ?? 0)
})

function measureRowRef(el: Element | ComponentPublicInstance | null) {
if (!virtualizer || !el) return
virtualizer.value.measureElement(el as Element)
}

// Re-measure rows whose expanded state toggled. TanStack's ResizeObserver is
// attached to the main `<tr>`, but expansion inserts a *sibling* `<tr>` that
// doesn't resize the main row, so the RO never fires β€” this manual re-measure
// keeps the virtualizer's cached heights in sync. Diff the before/after state
// and only re-measure rows whose expanded flag actually changed. When the
// expanded state is the sentinel `true` (expand-all) we fall back to a full
// sweep since there are no individual row ids to diff against.
if (virtualizer) {
watch(
() => tableApi.getState().expanded,
(next, prev) => {
nextTick(() => {
const root = rootRef.value?.$el as HTMLElement | undefined
if (!root) return

const measureAll = () => {
root.querySelectorAll<HTMLElement>('[data-index]').forEach((row) => {
virtualizer.value.measureElement(row)
})
}

if (typeof next === 'boolean' || typeof prev === 'boolean') {
measureAll()
return
}

const prevMap = (prev ?? {}) as Record<string, boolean>
const nextMap = (next ?? {}) as Record<string, boolean>
const changedIds = new Set<string>()
for (const id in nextMap) if (!!nextMap[id] !== !!prevMap[id]) changedIds.add(id)
for (const id in prevMap) if (!!nextMap[id] !== !!prevMap[id]) changedIds.add(id)

changedIds.forEach((id) => {
const index = tableApi.getRow(id)?.index
if (index === undefined) return
const el = root.querySelector<HTMLElement>(`[data-index="${index}"]`)
if (el) virtualizer.value.measureElement(el)
})
})
},
{ deep: true }
)
}

function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue
}
Expand Down Expand Up @@ -525,8 +606,10 @@ defineExpose({
</script>

<template>
<DefineRowTemplate v-slot="{ row, style }">
<DefineRowTemplate v-slot="{ row, style, index }">
<tr
:ref="index !== undefined ? measureRowRef : undefined"
:data-index="index"
:data-selected="row.getIsSelected()"
:data-selectable="!!props.onSelect || !!props.onHover || !!props.onContextmenu"
:data-expanded="row.getIsExpanded()"
Expand Down Expand Up @@ -571,7 +654,7 @@ defineExpose({
</td>
</tr>

<tr v-if="row.getIsExpanded()" data-slot="tr" :class="ui.tr({ class: [props.ui?.tr] })">
<tr v-if="row.getIsExpanded()" data-slot="tr" :class="ui.tr({ class: [props.ui?.tr] })" :style="style">
<td :colspan="row.getAllCells().length" data-slot="td" :class="ui.td({ class: [props.ui?.td] })">
<slot name="expanded" :row="row" />
</td>
Expand Down Expand Up @@ -631,7 +714,7 @@ defineExpose({
<ReuseRowTemplate
v-if="centerRows[virtualRow.index]"
:row="centerRows[virtualRow.index]!"
:style="{ height: `${virtualRow.size}px` }"
:index="virtualRow.index"
/>
</template>
<tr v-if="virtualPaddingBottom > 0" :style="{ height: `${virtualPaddingBottom}px` }" aria-hidden="true">
Expand Down
Loading