Composant de sélection dropdown avec tableau dans Vue

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.

Étiquettes: vuejs element-ui table-component dropdown custom-component

Publié le 20 juin à 16h05