vue 自定义下拉选择框组件

99 阅读2分钟

使用

可以通过设置样式,来控制多选时显示多少行,

.select-display {
  max-height: 100px;
  overflow-y: auto;
}

数据格式

selected: null, // 单选
multiSelected: [], // 多选
options: [
  { label: "载具1", value: "1" },
  { label: "载具2", value: "2" },
  { label: "载具3", value: "3" },
  { label: "载具4", value: "4" },
  { label: "载具5", value: "5" }
]

单选

<CustomSelect
  v-model="fromData.containerType"
  :options="descriptionOptions"
  :disabled="['view', 'edit'].includes(action)"
  :multiple="false"
  placeholder="请选择"
 />

多选

<CustomSelect
  v-model="fromData.containerType"
  :options="descriptionOptions"
  :disabled="['view', 'edit'].includes(action)"
  :multiple="true"
  placeholder="请选择"
 />

CustomSelect 源码

<template>
  <div
    class="custom-select"
    :class="{
      'is-open': dropdownVisible,
      'is-focus': isFocus,
      'is-disabled': disabled,
    }"
    tabindex="0"
    @mousedown="handleMousedown"
    @focusout="handleBlur"
  >
    <!-- 已选择项展示区 -->
    <div class="select-display" @click.stop>
      <!-- 多选 -->
      <template v-if="multiple">
        <span
          v-for="(item, index) in selectedOptions"
          :key="index"
          class="select-tag"
        >
          {{ item.label }}
          <span class="remove-tag" @click.stop="removeOption(item)">
            <img src="@/assets/tag-x.svg" class="tag-x">
          </span>
        </span>
        <!-- filterable 输入框 -->
        <input
          v-if="filterable"
          ref="filterInput"
          v-model="searchQuery"
          class="filter-input"
          :placeholder="!selectedOptions.length ? placeholder : ''"
          @focus="handleFocus"
          @input="handleInput"
        >
        <span v-else-if="!selectedOptions.length" class="placeholder">
          {{ placeholder }}
        </span>
      </template>

      <!-- 单选 -->
      <template v-else>
        <input
          v-if="filterable"
          ref="filterInput"
          v-model="searchQuery"
          class="filter-input"
          :placeholder="!hasValue ? placeholder : ''"
          @focus="handleFocus"
          @input="handleInput"
        >
        <span v-else class="placeholder">
          {{ selectedLabel || placeholder }}
        </span>
      </template>
    </div>

    <!-- 清空按钮 -->
    <span
      v-if="clearable && hasValue"
      class="clear-btn"
      @click.stop="clearSelection"
    >
      <img src="@/assets/tag-x.svg" class="tag-x">
    </span>

    <!-- 下拉箭头 -->
    <span class="arrow" :class="{ 'arrow-open': dropdownVisible }">
      <img src="@/assets/tag-down.svg" class="tag-x">
    </span>

    <!-- 下拉列表 -->
    <transition name="fade">
      <ul v-if="dropdownVisible" class="dropdown">
        <li
          v-for="(item, index) in filteredOptions"
          :key="index"
          class="dropdown-item"
          :class="{ selected: isSelected(item) }"
          @click.stop="selectOption(item)"
        >
          <span>{{ item.label }}</span>
          <span v-if="isSelected(item)" class="checkmark"></span>
        </li>
        <li v-if="filteredOptions.length === 0" class="dropdown-item no-result">
          无匹配结果
        </li>
      </ul>
    </transition>
  </div>
</template>

<script>
export default {
  name: 'CustomSelect',
  props: {
    value: {
      type: [String, Number, Array],
      default: ''
    },
    placeholder: { type: String, default: '请选择' },
    options: { type: Array, default: () => [] },
    multiple: { type: Boolean, default: false },
    disabled: { type: Boolean, default: false },
    clearable: { type: Boolean, default: false },
    filterable: { type: Boolean, default: false },
    remote: { type: Boolean, default: false },
    remoteMethod: { type: Function, default: null }
  },
  data() {
    return {
      dropdownVisible: false,
      isFocus: false,
      searchQuery: '',
      selectedValues: this.multiple
        ? Array.isArray(this.value) ? this.value : []
        : this.value
    }
  },
  computed: {
    selectedOptions() {
      return this.multiple
        ? this.options.filter(opt => this.selectedValues.includes(opt.value))
        : []
    },
    selectedLabel() {
      if (this.multiple) return ''
      const selected = this.options.find(opt => opt.value === this.selectedValues)
      return selected ? selected.label : ''
    },
    hasValue() {
      return this.multiple
        ? this.selectedValues.length > 0
        : this.selectedValues !== null && this.selectedValues !== ''
    },
    filteredOptions() {
      if (!this.filterable || this.remote) return this.options
      const q = this.searchQuery.toLowerCase()
      return this.options.filter(opt => opt.label.toLowerCase().includes(q))
    }
  },
  watch: {
    value(newVal) {
      this.selectedValues = newVal
      if (!this.multiple && this.filterable) {
        this.searchQuery = this.selectedLabel
      }
    }
  },
  mounted() {
    document.addEventListener('click', this.handleClickOutside)
    if (!this.multiple && this.filterable) {
      this.searchQuery = this.selectedLabel
    }
  },
  beforeDestroy() {
    document.removeEventListener('click', this.handleClickOutside)
  },
  methods: {
    handleClickOutside(event) {
      if (!this.$el.contains(event.target)) {
        this.dropdownVisible = false
        this.isFocus = false
        if (!this.multiple && this.filterable) {
          this.searchQuery = this.selectedLabel
        }
      }
    },
    handleMousedown(e) {
      if (this.disabled) return
      // 防止 blur 关闭
      e.preventDefault()
      this.dropdownVisible = true
      this.isFocus = true
      if (this.filterable) {
        this.$nextTick(() => this.$refs.filterInput?.focus())
      }
    },
    handleBlur() {
      this.dropdownVisible = false
      this.isFocus = false
      if (!this.multiple && this.filterable) {
        this.searchQuery = this.selectedLabel
      }
    },
    clearSelection() {
      this.selectedValues = this.multiple ? [] : null
      this.$emit('input', this.selectedValues)
      this.searchQuery = ''
    },
    toggleDropdown() {
      if (this.disabled) return
      this.dropdownVisible = !this.dropdownVisible
      this.isFocus = this.dropdownVisible
      if (this.dropdownVisible && this.filterable) {
        this.$nextTick(() => this.$refs.filterInput?.focus())
      }
    },
    handleFocus() {
      this.dropdownVisible = true
    },
    handleInput() {
      if (this.remote && this.remoteMethod) {
        this.remoteMethod(this.searchQuery)
      }
    },
    selectOption(item) {
      if (this.multiple) {
        if (this.selectedValues.includes(item.value)) {
          this.selectedValues = this.selectedValues.filter(val => val !== item.value)
        } else {
          this.selectedValues.push(item.value)
        }
      } else {
        this.selectedValues = item.value
        this.dropdownVisible = false
        if (this.filterable) this.searchQuery = item.label
      }
      this.$emit('input', this.selectedValues)
    },
    removeOption(item) {
      this.selectedValues = this.selectedValues.filter(val => val !== item.value)
      this.$emit('input', this.selectedValues)
    },
    isSelected(item) {
      return this.multiple
        ? this.selectedValues.includes(item.value)
        : this.selectedValues === item.value
    }
  }
}
</script>

<style scoped>
.custom-select {
  position: relative;
  display: inline-block;
  width: 100%;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 5px 10px;
  background: #fff;
  cursor: pointer;
  transition: border-color 0.3s;
}
.custom-select.is-focus,
.custom-select.is-open {
  border-color: #2354e6;
  box-shadow: 0 0 4px rgba(64, 158, 255, 0.4);
}
.select-display {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  min-height: 24px;
}
.placeholder {
  color: #999;
}
.filter-input {
  flex: 1;
  min-width: 40px;
  border: none;
  outline: none;
  font-size: 14px;
  color: #333;
}
.arrow {
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  transition: transform 0.3s;
}
.arrow-open {
  transform: translateY(-50%) rotate(180deg);
}
.dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  max-height: 200px;
  overflow-y: auto;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  z-index: 10;
  list-style: none;
  margin: 0;
  padding: 0;
}
.dropdown-item {
  padding: 5px 10px;
  cursor: pointer;
  color: #333;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.dropdown-item:hover {
  background: #f5f7fa;
  color: #409eff;
}
.dropdown-item.selected {
  color: #409eff;
  font-weight: 500;
}
.checkmark {
  color: #409eff;
}
.no-result {
  text-align: center;
  color: #999;
  font-size: 12px;
  padding: 6px 0 0 10px;
}
.select-tag {
  background: #eef1fa;
  color: #333;
  padding: 2px 5px;
  margin: 2px;
  display: flex;
  align-items: center;
  border-radius: 4px;
  border: 1px solid #eef1fa;
}
.remove-tag {
  margin-left: 4px;
  cursor: pointer;
}
.tag-x {
  position: relative;
  top: 2px;
}
.clear-btn {
  position: absolute;
  right: 27px;
  top: 50%;
  transform: translateY(-50%);
  cursor: pointer;
  color: #999;
  transition: color 0.2s;
}
.clear-btn:hover {
  color: #409eff;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
.custom-select.is-disabled {
  background: #f5f5f5;
  border-color: #ddd;
  color: #aaa;
  cursor: not-allowed;
}
.custom-select.is-disabled .placeholder {
  color: #bbb;
}
</style>