使用
可以通过设置样式,来控制多选时显示多少行,
.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>