Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, useForwardProps } from 'reka-ui'
import { upperFirst } from 'scule'
import { defu } from 'defu'
Expand Down Expand Up @@ -284,7 +300,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 @@ -293,6 +309,11 @@ const [DefineRowTemplate, ReuseRowTemplate] = createReusableTemplate<{ row: Tabl
style: {
type: Object,
required: false
},
index: {
type: Number,
required: false,
default: undefined
}
}
})
Expand Down Expand Up @@ -432,6 +453,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 @@ -444,6 +476,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 @@ -519,8 +600,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 @@ -565,7 +648,7 @@ defineExpose({
</td>
</tr>

<tr v-if="row.getIsExpanded()" data-slot="tr" :class="ui.tr({ class: [uiProp?.tr] })">
<tr v-if="row.getIsExpanded()" data-slot="tr" :class="ui.tr({ class: [uiProp?.tr] })" :style="style">
<td :colspan="row.getAllCells().length" data-slot="td" :class="ui.td({ class: [uiProp?.td] })">
<slot name="expanded" :row="row" />
</td>
Expand Down Expand Up @@ -625,7 +708,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