fix(SelectMenu): correct virtualization height estimation when using item-description slot
๐ Linked issue
This PR fixes a virtualization bug when using item-description slot with SelectMenu. No existing issue was filed as the bug was discovered and fixed directly.
โ Type of change
- [ ] ๐ Documentation (updates to the documentation or readme)
- [x] ๐ Bug fix (a non-breaking change that fixes an issue)
- [ ] ๐ Enhancement (improving an existing functionality)
- [ ] โจ New feature (a non-breaking change that adds functionality)
- [ ] ๐งน Chore (updates to the build process or auxiliary tools and libraries)
- [ ] โ ๏ธ Breaking change (fix or feature that would cause existing functionality to change)
๐ Description
Problem:
When using the item-description slot with virtualize enabled in SelectMenu, items overlap and scrolling breaks due to incorrect height estimation.
Visual Evidence:
Before (Broken):
Items overlap because virtualizer estimates 32px height but actual rendered height is 52px with descriptions
After (Fixed):
Items render correctly with proper 52px spacing when descriptions are present
Reproduction:
<script setup lang="ts">
import type { SelectMenuItem } from '@nuxt/ui'
const items: SelectMenuItem[] = Array(1000)
.fill(0)
.map((_, i) => ({
label: `item-${i}`,
value: i
}))
</script>
<template>
<USelectMenu
:items="items"
virtualize
class="w-48"
>
<template #item-description="{ item }">
Custom description for {{ item.label }}
</template>
</USelectMenu>
</template>
Root Cause:
The original virtualizerProps used a fixed estimateSize of 24-40px (based on size) for all items, regardless of whether they had descriptions:
// Before- always used small sizes
const virtualizerProps = toRef(() => !!props.virtualize && defu(typeof props.virtualize === 'boolean' ? {} : props.virtualize, {
estimateSize: ({
xs: 24,
sm: 28,
md: 32,
lg: 36,
xl: 40
})[props.size || 'md']
}))
However, the template renders descriptions when either data exists OR the slot is used:
<span v-if="isSelectItem(item) && (get(item, props.descriptionKey as string) || !!slots['item-description'])">
<slot name="item-description" :item="item" :index="index">
{{ get(item, props.descriptionKey as string) }}
</slot>
</span>
This mismatch caused the virtualizer to estimate 32px heights even when descriptions were being rendered (requiring 52px), resulting in overlapping items and broken scrolling.
Solution:
The fix detects whether any item will have a description rendered (either from data OR from the slot) and adjusts the estimateSize accordingly (look at code diff)
๐ Checklist
- [ ] I have linked an issue or discussion.
- [ ] I have updated the documentation accordingly.
Thanks for the suggestion! I explored implementing per-item estimateSize using a function.
The Challenge:
reka-ui's ComboboxVirtualizer currently only accepts estimateSize as a static number, not a function. The underlying @tanstack/vue-virtual DOES support estimateSize: (index: number) => number, but reka-ui wraps it and doesn't expose this API to users.
Here's the relevant code in reka-ui:
// In reka-ui/ListboxVirtualizer.vue line 67
estimateSize() {
return props.estimateSize ?? 28 // Always returns a static number
}
Closing in favor of https://github.com/nuxt/ui/commit/56ae8e7199b8d5e3b5a169bd63c767724abfb582.
I've also opened a PR in Reka UI to be able to pass a function: https://github.com/unovue/reka-ui/pull/2288. The new getEstimateSize util is ready to handle it once released: https://github.com/nuxt/ui/blob/v4/src/runtime/utils/virtualizer.ts#L37-L42.
Hey @benjamincanac ! Just noticed the release notes didnโt include my name for this PR โ no worries at all, but if possible could it be added for attribution? Thank you!
@aligu7 What do you mean? This PR hasn't been merged ๐ฌ
@benjamincanac , after your changes in https://github.com/nuxt/ui/commit/56ae8e7 this issue hasn't been fixed completely, I know we are waiting for the Reka UI's response in https://github.com/unovue/reka-ui/pull/2288 but let's until then have a working solution from me perhaps that has been implemented above or something else. Because this issue hasn't been fixed yet, and you closed this PR.