When embedding input fields or dropdown selects inside table cells, rendering large datasest can freeze the browser or exhaust memory before the options even finish loading. A virtual scrolling strategy miitgates this by only rendering visible items.
Below is an implementation using vue-virtual-scroller to build a performant select popover suitable for massive option lists in Vue 2 environments.
Component Usage
<virtual-scroll-select
:options="records"
identifier="uid"
field="selectedUid"
:value.sync="row.selectedUid"
@selection="handleUidChange($index, 'selectedUid', row.selectedUid, row)">
</virtual-scroll-select>
Template Structure
<template>
<div class="vscroll-select">
<el-popover
ref="dropdown"
placement="bottom"
popper-class="vscroll-popover"
:visible-arrow="false"
@show="handleOpen"
@hide="handleClose"
v-model="visible"
trigger="focus">
<div class="viewport" :style="{ '--viewH': viewHeight }">
<RecycleScroller
ref="scroller"
class="scroller-area"
:key="fullPageKey"
:items="matchedItems"
:item-size="null"
:page-mode="false"
size-field="height"
:key-field="identifier"
item-class="entry-item"
>
<template #default="{ item }">
<div
class="entry"
:class="{ 'chosen': item[identifier] === activeUid }"
@click="pickOption(item)"
>
<span>{{ item[identifier] }}</span>
</div>
</template>
<template #empty v-if="matchedItems.length === 0">
<div class="no-data">No matching entries</div>
</template>
</RecycleScroller>
</div>
</el-popover>
<custom-input
v-popover:dropdown
v-model="query"
placeholder="Select..."
@input="performFilter">
</custom-input>
</div>
</template>
Script Logic
<script>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import CustomInput from '@/components/CustomInput'
import debounce from 'lodash/debounce'
export default {
name: 'VirtualScrollSelect',
components: {
RecycleScroller,
CustomInput
},
props: {
options: {
type: Array,
required: true
},
value: {
type: String,
default: ''
},
identifier: {
type: String,
default: 'id'
},
field: {
type: String,
default: ''
}
},
data () {
return {
query: '',
matchedItems: [],
visible: false,
activeUid: '',
fullPageKey: true
}
},
computed: {
viewHeight () {
const count = this.matchedItems.length
if (count > 6) return '200px'
if (count === 0) return '40px'
return `${count * 32}px`
}
},
watch: {
options: {
handler (newList) {
this.matchedItems = newList
},
deep: true
},
value (newVal) {
this.activeUid = newVal
this.query = newVal
}
},
methods: {
handleOpen () {
this.$nextTick(() => {
const idx = this.options.findIndex(opt => opt[this.identifier] === this.value)
this.$refs.scroller.scrollToItem(idx)
})
},
performFilter: debounce(function (val) {
this.$emit(`update:${this.field}`, val)
const term = val.toLowerCase()
this.matchedItems = this.options.filter(opt =>
opt[this.identifier].toLowerCase().includes(term)
)
}, 300),
pickOption (opt) {
this.activeUid = opt[this.identifier]
this.query = opt[this.identifier]
this.visible = false
this.$emit(`update:${this.field}`, opt[this.identifier])
this.$emit('selection', opt)
},
handleClose () {
if (this.query !== this.activeUid) {
this.query = this.activeUid
this.$emit(`update:${this.field}`, this.activeUid)
}
this.matchedItems = this.options
}
},
mounted () {
this.activeUid = this.value
this.query = this.value
this.matchedItems = this.options
}
}
</script>
Styling
<style lang="less" scoped>
.vscroll-select {
width: 100%;
position: relative;
}
.viewport {
height: var(--viewH);
background: #fefefe;
.scroller-area {
width: 100%;
height: 100%;
::v-deep .entry-item {
border-bottom: 1px solid #e9eaf0;
font-size: 14px;
cursor: pointer;
height: 32px;
line-height: 19px;
padding: 6px 12px;
box-sizing: border-box;
.entry {
color: #616163;
text-align: center;
background: #fff;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
&:hover {
color: #fff;
background: #6d71f9;
}
span {
display: inline-block;
width: 100%;
height: 100%;
padding: 0 10px;
box-sizing: border-box;
}
}
.chosen {
color: #fff;
background: #6d71f9;
}
}
.no-data {
padding: 10px 0;
text-align: center;
color: #999;
font-size: 14px;
}
&::-webkit-scrollbar {
width: 6px;
height: 6px;
transition: background-color .2s linear, width .2s ease-in-out;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background: rgba(0,0,0,0.1);
}
&::-webkit-scrollbar-thumb:hover {
width: 10px;
background: rgba(0,0,0,0.3);
}
&::-webkit-scrollbar-track {
border-radius: 10px;
background: rgba(0,0,0,0.2);
}
&::-webkit-scrollbar-thumb:active {
width: 10px;
background: rgba(0,0,0,.6);
}
}
}
</style>
<style lang="less">
.vscroll-popover {
margin: 0;
border-color: #e9eaf0;
border-radius: 0;
box-shadow: 0 0 10px rgba(168, 174, 187, 0.25);
border: 1px solid #E4E7ED;
padding: 0;
box-sizing: border-box;
}
</style>