Le composant select d'Element UI présente des performances médiocres lors du rendu de grands volumes de données. Une solution alternative a été développée en combinant un tableau avec un champ de saisie, en s'inspirnat partiellement du code source du select originel.
Le composant personnalisé offre une expérience plus fluide pour la manipulation de listes conséquentes, avec des fonctionnalités de sélection multiple, de pagination et de filtrage par mot-clé.
Démonstration
Le composant permet la sélection à la fois simple et multiple, avec un affichage des éléments sélectionnés sous forme de balises. Le panneau déroulant intègre un tableau paginé avec support du chargement asynchrone.
Implémentation du composant
<template>
<div :id="componentId">
<div class="selector-wrapper" v-if="multiSelect">
<div class="tag-container">
<el-tag v-for="(selItem, idx) in displayedSelections" :key="idx"
closable @close="removeSelection(selItem)"
size="small" type="info" effect="plain">
{{ selItem[displayField] || '' }}
</el-tag>
<input type="text" ref="searchInput" :value="currentValue"
:placeholder="selectionPlaceholder" @focus="handleFocus"
@input="updateSearch($event.target.value)">
<i class="el-icon-arrow-down indicator" :class="{'rotated': isOpen}"></i>
</div>
</div>
<el-input v-else :value="currentValue" @input="updateSearch"
@focus="handleFocus" @blur="handleBlur" clearable
:placeholder="placeholderText" ref="mainInput"></el-input>
<div class="dropdown-panel" :style="panelPosition"
v-show="isOpen" ref="dropdownContainer">
<div class="data-table">
<el-table v-loading="isLoading" :data="dataList"
@row-click="selectRow" stripe size="mini"
ref="dataTable" fit border highlight-current-row
@selection-change="processSelection" @select="handleSingleSelect">
<el-table-column v-if="multiSelect" type="selection"
width="55" align="center" />
<el-table-column v-if="showRowNumbers" label="N°"
type="index" align="center" width="50"></el-table-column>
<el-table-column v-for="col in tableColumns" :key="col.field"
:label="col.title" :prop="col.field" :align="col.alignment"
:width="col.width" :header-align="col.headerAlignment">
<template slot-scope="{ row }">
<span>{{ row[col.field] }}</span>
</template>
</el-table-column>
</el-table>
<el-pagination v-if="enablePagination" small
:total="totalRecords" :page-size="pageSize"
layout="prev, pager, next, total"
@current-change="goToPage" />
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: "DropdownTable",
props: {
placeholderText: String,
currentValue: String,
apiConfig: {
type: Object,
default: () => ({
endpoint: '',
httpMethod: 'get'
})
},
tableColumns: Array,
panelStyle: String,
displayField: String,
showRowNumbers: Boolean,
enablePagination: {
type: Boolean,
default: true
},
additionalParams: {
type: Object,
default: () => ({})
},
multiSelect: {
type: Boolean,
default: false
}
},
data() {
return {
isOpen: false,
panelPosition: 'width:500px',
dataList: [],
totalRecords: 0,
isLoading: true,
paginationQuery: {
currentPage: 1,
itemsPerPage: 5
},
searchKeyword: '',
componentId: '0',
currentSelections: [],
displayedSelections: [],
selectionPlaceholder: ''
}
},
mounted() {
this.componentId = `dropdown-${Date.now()}`
this.selectionPlaceholder = this.placeholderText
},
methods: {
goToPage(page) {
this.paginationQuery.currentPage = page
this.fetchData()
},
updateSearch(value) {
this.$emit('input', value)
this.searchKeyword = value
this.paginationQuery.currentPage = 1
this.fetchData()
},
handleFocus(event) {
this.panelPosition = `${this.panelStyle};position:absolute;z-index:999999;`
this.isOpen = true
this.searchKeyword = event.target.value
document.addEventListener('click', this.closeDropdown)
this.fetchData()
},
handleBlur() {},
async fetchData() {
const queryParams = { ...this.additionalParams }
if (this.enablePagination) {
queryParams[this.displayField] = this.searchKeyword
} else if (!this.searchKeyword) {
this.isLoading = false
return
} else {
queryParams[this.displayField] = this.searchKeyword
}
this.isLoading = true
this.currentSelections = []
try {
const response = await this.queryApi(queryParams)
this.dataList = response.data.rows
this.totalRecords = response.data.total
this.isLoading = false
this.$nextTick(() => {
const selectedIds = this.displayedSelections.map(item => item.id)
this.dataList.forEach(item => {
if (selectedIds.includes(item.id)) {
this.$refs.dataTable.toggleRowSelection(item)
}
})
})
} catch (error) {
console.error('Data fetch failed:', error)
this.isLoading = false
}
},
queryApi(params) {
const config = {
url: this.apiConfig.endpoint,
method: this.apiConfig.httpMethod,
[this.apiConfig.httpMethod === 'get' ? 'params' : 'data']: params
}
return axios(config)
},
selectRow(row) {
if (this.multiSelect) {
this.$refs.dataTable.toggleRowSelection(row)
this.$emit('input', '')
} else {
this.isOpen = false
this.$emit('row-selected', row)
this.$emit('input', row[this.displayField])
document.removeEventListener('click', this.closeDropdown)
}
},
closeDropdown(event) {
if (event.path.some(element => element.id === this.componentId)) return
this.isOpen = false
document.removeEventListener('click', this.closeDropdown)
},
processSelection(selectedItems) {
if (this.currentSelections.length === 0 && selectedItems.length === 0) return
if (selectedItems.length > this.currentSelections.length) {
selectedItems.forEach(newItem => {
if (!this.currentSelections.some(existing => existing.id === newItem.id)) {
const alreadyDisplayed = this.displayedSelections.some(item => item.id === newItem.id)
if (!alreadyDisplayed) {
this.displayedSelections = [...this.displayedSelections, newItem]
}
}
})
} else {
this.currentSelections.forEach(oldItem => {
if (!selectedItems.some(current => current.id === oldItem.id)) {
const index = this.displayedSelections.findIndex(item => item.id === oldItem.id)
if (index > -1) {
this.displayedSelections.splice(index, 1)
}
}
})
}
this.currentSelections = selectedItems
this.selectionPlaceholder = this.displayedSelections.length ? '' : this.placeholderText
this.$emit('selection-changed', this.displayedSelections)
},
handleSingleSelect() {
this.$emit('input', '')
},
removeSelection(item) {
const index = this.displayedSelections.findIndex(i => i.id === item.id)
if (index > -1) {
this.displayedSelections.splice(index, 1)
}
this.$refs.dataTable.toggleRowSelection(item)
}
}
}
</script>
<style lang="scss" scoped>
.selector-wrapper {
display: inline-block;
position: relative;
width: 100%;
min-width: 240px;
background-color: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-sizing: border-box;
color: #606266;
font-size: inherit;
padding: 0 15px;
transition: border-color 0.2s;
.indicator {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
transition: transform 0.3s;
&.rotated {
transform: translateY(-50%) rotate(180deg);
}
}
.tag-container {
min-height: 36px;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
line-height: normal;
white-space: normal;
/deep/ .el-tag {
margin: 0 3px 1px 0;
}
input {
flex: 1;
border: none;
outline: none;
padding: 0;
height: 28px;
background: transparent;
font-size: 14px;
color: #666;
&::placeholder {
color: #c0c4cc;
}
}
}
}
</style>
Utilisation du composant
<DropdownTable
v-model="formData.selectedName"
v-if="isComponentVisible"
placeholderText="Sélectionner un élément"
:apiConfig="{endpoint: '/api/data/list', httpMethod: 'get'}"
:enablePagination="true"
:tableColumns="columnDefinitions"
:showRowNumbers="true"
displayField="name"
:panelStyle="'width:550px'"
:additionalParams="{filterType: 'active'}"
:multiSelect="true"
@selection-changed="handleMultiSelection"
@row-selected="handleSingleRowSelection"
/>
<script>
export default {
data() {
return {
columnDefinitions: [
{ title: 'Désignation', field: 'name' },
{ title: 'Code', field: 'code', alignment: 'center' },
{ title: 'Statut', field: 'status', width: '100px' }
],
formData: {
selectedName: '',
selectedIds: []
}
}
},
methods: {
handleSingleRowSelection(row) {
console.log('Ligne sélectionnée:', row)
},
handleMultiSelection(selectedItems) {
this.formData.selectedIds = selectedItems.map(item => item.id)
}
}
}
</script>
Le composant gère automatiquement la synchronisation entre les sélections et l'affichage des balises, avec une gestion efficace de la pagination et du filtrage côté serveur. La structure modulaire permet une personnalisation facile des colonnes, des requêtes API et des styles d'affichage.