perf: virtualize FormDropdownMenu to reduce DOM nodes and image requests

- Integrate VirtualGrid into FormDropdownMenu for virtualized rendering
- Only render visible items (~20-30) instead of all items (100+)
- Add computed properties for grid configuration per layout mode
- Extend VirtualGrid slot to provide original item index
- Change container from max-h to fixed h for proper virtualization

Amp-Thread-ID: https://ampcode.com/threads/T-019c0ca8-be8d-770e-ab31-349937cd2acf
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
bymyself
2026-01-29 21:01:57 -08:00
parent 985d024a6e
commit a32b386d9f
2 changed files with 100 additions and 42 deletions

View File

@@ -6,12 +6,12 @@
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div
v-for="item in renderedItems"
v-for="(item, i) in renderedItems"
:key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item
>
<slot name="item" :item="item" />
<slot name="item" :item="item" :index="state.start + i" />
</div>
</div>
<div :style="bottomSpacerStyle" />

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { MaybeRefOrGetter } from 'vue'
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
@@ -26,7 +27,7 @@ interface Props {
updateKey?: MaybeRefOrGetter<unknown>
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: DropdownItem, index: number): void
}>()
@@ -37,12 +38,70 @@ const layoutMode = defineModel<LayoutMode>('layoutMode')
const sortSelected = defineModel<OptionId>('sortSelected')
const searchQuery = defineModel<string>('searchQuery')
// Handle item selection
// VirtualGrid configuration based on layout mode
const maxColumns = computed(() => {
switch (layoutMode.value) {
case 'grid':
return 4
case 'list':
case 'list-small':
return 1
default:
return 4
}
})
const defaultItemHeight = computed(() => {
switch (layoutMode.value) {
case 'grid':
return 120
case 'list':
return 64
case 'list-small':
return 40
default:
return 120
}
})
const defaultItemWidth = computed(() => {
switch (layoutMode.value) {
case 'grid':
return 89
case 'list':
case 'list-small':
return 380
default:
return 89
}
})
const gridStyle = computed<CSSProperties>(() => ({
display: 'grid',
gridTemplateColumns:
layoutMode.value === 'grid' ? 'repeat(4, 1fr)' : 'repeat(1, 1fr)',
gap:
layoutMode.value === 'grid'
? '1rem 0.5rem'
: layoutMode.value === 'list'
? '0.5rem'
: '0.25rem',
padding: '1rem',
width: '100%'
}))
type VirtualDropdownItem = DropdownItem & { key: string }
const virtualItems = computed<VirtualDropdownItem[]>(() =>
props.items.map((item) => ({
...item,
key: String(item.id)
}))
)
</script>
<template>
<div
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
>
<!-- Filter -->
<FormDropdownMenuFilter
@@ -59,45 +118,44 @@ const searchQuery = defineModel<string>('searchQuery')
:searcher
:update-key="updateKey"
/>
<!-- List -->
<div class="relative flex h-full mt-2 overflow-y-scroll">
<!-- List Container -->
<div class="relative mt-2 min-h-0 flex-1">
<!-- Empty State -->
<div
:class="
cn(
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
{
'grid-cols-4': layoutMode === 'grid',
'grid-cols-1 gap-y-2': layoutMode === 'list',
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
}
)
"
v-if="items.length === 0"
class="flex h-50 items-center justify-center"
>
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
<div
v-if="items.length === 0"
class="h-50 col-span-full flex items-center justify-center"
>
<i
:title="$t('g.noItems')"
:aria-label="$t('g.noItems')"
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
/>
</div>
<!-- Item -->
<FormDropdownMenuItem
v-for="(item, index) in items"
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:media-src="item.mediaSrc"
:name="item.name"
:label="item.label"
:metadata="item.metadata"
:layout="layoutMode"
@click="emit('item-click', item, index)"
<i
:title="$t('g.noItems')"
:aria-label="$t('g.noItems')"
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
/>
</div>
<!-- Virtualized Grid -->
<VirtualGrid
v-else
:key="layoutMode"
:items="virtualItems"
:grid-style="gridStyle"
:max-columns="maxColumns"
:default-item-height="defaultItemHeight"
:default-item-width="defaultItemWidth"
:buffer-rows="2"
class="h-full"
>
<template #item="{ item, index }">
<FormDropdownMenuItem
:index="index"
:selected="isSelected(item, index)"
:media-src="item.mediaSrc"
:name="item.name"
:label="item.label"
:metadata="item.metadata"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>
</template>
</VirtualGrid>
</div>
</div>
</template>