在Vue3项目开发中,表单交互设计经常需要实现点击输入框弹出下拉选项的功能。这种交互模式相比传统的<select>元素具有更高的UI定制自由度,能够完美融入现代Web应用的设计语言。
我最近在开发一个后台管理系统时,就遇到了这样的需求:用户点击输入框后,下方需要显示可单选的下拉列表,选择后自动关闭下拉并填充选中值。这种组件在用户资料编辑、数据筛选等场景非常常见。
为什么选择Vue3来实现?相比Vue2,Vue3的Composition API让我们可以更灵活地封装这类交互逻辑。特别是:
v-model的改进支持多个双向绑定Teleport组件解决下拉菜单的定位问题html复制<template>
<div class="select-container">
<input
v-model="inputValue"
@click="toggleDropdown"
@blur="handleBlur"
placeholder="请选择"
/>
<transition name="fade">
<ul v-show="isOpen" class="dropdown-menu">
<li
v-for="item in options"
:key="item.value"
@mousedown="selectItem(item)"
>
{{ item.label }}
</li>
</ul>
</transition>
</div>
</template>
这里有几个关键设计点:
@mousedown而非@click处理选项选择,避免blur事件先触发导致下拉关闭transition包裹下拉菜单实现平滑的显隐动画value和label的数据结构,便于后续扩展css复制.select-container {
position: relative;
width: 200px;
}
.dropdown-menu {
position: absolute;
width: 100%;
max-height: 200px;
overflow-y: auto;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 100;
}
.dropdown-menu li {
padding: 8px 12px;
cursor: pointer;
}
.dropdown-menu li:hover {
background-color: #f5f5f5;
}
关键提示:必须为容器设置
position: relative,下拉菜单使用position: absolute才能正确定位。z-index要确保高于页面其他元素。
javascript复制import { ref } from 'vue';
export default {
props: {
options: {
type: Array,
required: true,
validator: (value) => {
return value.every(item => 'value' in item && 'label' in item)
}
},
modelValue: {
type: [String, Number],
default: ''
}
},
setup(props, { emit }) {
const isOpen = ref(false);
const inputValue = ref('');
// 初始化时设置默认值
if (props.modelValue) {
const selected = props.options.find(opt => opt.value === props.modelValue);
if (selected) inputValue.value = selected.label;
}
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const selectItem = (item) => {
inputValue.value = item.label;
emit('update:modelValue', item.value);
isOpen.value = false;
};
const handleBlur = () => {
// 延迟关闭以允许点击事件完成
setTimeout(() => {
isOpen.value = false;
}, 200);
};
return {
isOpen,
inputValue,
toggleDropdown,
selectItem,
handleBlur
};
}
};
双向数据绑定:
modelValue prop和update:modelValue事件实现v-modelinputValue用于显示选中的label数据验证:
value和label属性时序控制:
setTimeout处理blur事件避免与点击冲突@mousedown优先于@blur执行javascript复制const searchQuery = ref('');
const filteredOptions = computed(() => {
return props.options.filter(option =>
option.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
// 在模板中将v-for的options替换为filteredOptions
javascript复制// 在setup()中添加
const highlightedIndex = ref(-1);
const handleKeyDown = (e) => {
if (!isOpen.value) return;
switch(e.key) {
case 'ArrowDown':
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
break;
case 'ArrowUp':
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
break;
case 'Enter':
if (highlightedIndex.value >= 0) {
selectItem(filteredOptions.value[highlightedIndex.value]);
}
break;
case 'Escape':
isOpen.value = false;
break;
}
};
// 在模板的input上添加@keydown="handleKeyDown"
当组件在overflow:hidden的容器中时,下拉菜单可能被裁剪。解决方案:
html复制<teleport to="body">
<transition name="fade">
<ul v-show="isOpen" class="dropdown-menu">
<!-- 选项内容 -->
</ul>
</transition>
</teleport>
需要调整样式:
css复制.dropdown-menu {
position: fixed;
/* 通过JS计算位置 */
}
当选项很多时(如超过100条),使用vue-virtual-scroller:
javascript复制import { RecycleScroller } from 'vue-virtual-scroller';
// 在组件中注册
components: { RecycleScroller }
// 修改模板
<recycle-scroller
v-show="isOpen"
class="dropdown-menu"
:items="filteredOptions"
:item-size="32"
key-field="value"
>
<template v-slot="{ item }">
<li @mousedown="selectItem(item)">
{{ item.label }}
</li>
</template>
</recycle-scroller>
html复制<input
aria-haspopup="listbox"
aria-expanded="isOpen"
aria-controls="dropdown-menu"
/>
<ul
id="dropdown-menu"
role="listbox"
aria-label="选择选项"
>
<li
v-for="(item, index) in options"
role="option"
:aria-selected="inputValue === item.label"
:class="{ 'highlighted': highlightedIndex === index }"
>
{{ item.label }}
</li>
</ul>
基础实现中点击页面其他区域无法关闭下拉菜单。解决方案:
javascript复制import { onClickOutside } from '@vueuse/core';
// 在setup()中
const dropdownRef = ref(null);
onClickOutside(dropdownRef, () => {
isOpen.value = false;
});
// 模板中给容器添加ref
<div ref="dropdownRef" class="select-container">
与VeeValidate等验证库集成时需要注意:
javascript复制// 在props中添加
validation: {
type: Object,
default: () => ({})
}
// 在模板中
<input
:class="{ 'error': validation.$error }"
@blur="validation.$touch"
/>
当options异步加载时,需要监听变化:
javascript复制watch(() => props.options, (newVal) => {
if (props.modelValue) {
const selected = newVal.find(opt => opt.value === props.modelValue);
if (selected) inputValue.value = selected.label;
}
});
以下是经过优化的完整实现:
html复制<template>
<div ref="dropdownRef" class="select-container">
<input
v-model="inputValue"
@click="toggleDropdown"
@keydown="handleKeyDown"
@blur="handleBlur"
:class="{ 'error': validation.$error }"
aria-haspopup="listbox"
aria-expanded="isOpen"
aria-controls="dropdown-menu"
placeholder="请选择"
/>
<teleport to="body">
<transition name="fade">
<ul
v-show="isOpen"
id="dropdown-menu"
class="dropdown-menu"
role="listbox"
aria-label="选择选项"
:style="dropdownStyle"
>
<li
v-for="(item, index) in filteredOptions"
:key="item.value"
@mousedown="selectItem(item)"
role="option"
:aria-selected="modelValue === item.value"
:class="{ 'highlighted': highlightedIndex === index }"
>
{{ item.label }}
</li>
</ul>
</transition>
</teleport>
</div>
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue';
import { onClickOutside } from '@vueuse/core';
export default {
props: {
options: {
type: Array,
required: true,
validator: (value) => value.every(item => 'value' in item && 'label' in item)
},
modelValue: {
type: [String, Number],
default: ''
},
validation: {
type: Object,
default: () => ({})
}
},
setup(props, { emit }) {
const dropdownRef = ref(null);
const isOpen = ref(false);
const inputValue = ref('');
const searchQuery = ref('');
const highlightedIndex = ref(-1);
const dropdownStyle = ref({});
const filteredOptions = computed(() => {
return props.options.filter(option =>
option.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const updatePosition = () => {
if (!dropdownRef.value) return;
const rect = dropdownRef.value.getBoundingClientRect();
dropdownStyle.value = {
top: `${rect.bottom + window.scrollY}px`,
left: `${rect.left + window.scrollX}px`,
width: `${rect.width}px`
};
};
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
updatePosition();
highlightedIndex.value = -1;
}
};
const selectItem = (item) => {
inputValue.value = item.label;
emit('update:modelValue', item.value);
isOpen.value = false;
props.validation.$touch();
};
const handleBlur = () => {
setTimeout(() => {
const selected = props.options.find(opt => opt.value === props.modelValue);
if (!selected) {
inputValue.value = '';
emit('update:modelValue', '');
}
}, 200);
};
const handleKeyDown = (e) => {
if (!isOpen.value && ['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
isOpen.value = true;
updatePosition();
return;
}
switch(e.key) {
case 'ArrowDown':
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
e.preventDefault();
break;
case 'ArrowUp':
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
e.preventDefault();
break;
case 'Enter':
if (highlightedIndex.value >= 0) {
selectItem(filteredOptions.value[highlightedIndex.value]);
}
break;
case 'Escape':
isOpen.value = false;
break;
default:
searchQuery.value += e.key;
setTimeout(() => searchQuery.value = '', 500);
}
};
watch(() => props.modelValue, (newVal) => {
if (newVal) {
const selected = props.options.find(opt => opt.value === newVal);
if (selected) inputValue.value = selected.label;
} else {
inputValue.value = '';
}
});
watch(() => props.options, (newVal) => {
if (props.modelValue) {
const selected = newVal.find(opt => opt.value === props.modelValue);
if (selected) inputValue.value = selected.label;
}
});
onClickOutside(dropdownRef, () => {
isOpen.value = false;
});
onMounted(() => {
if (props.modelValue) {
const selected = props.options.find(opt => opt.value === props.modelValue);
if (selected) inputValue.value = selected.label;
}
});
return {
dropdownRef,
isOpen,
inputValue,
filteredOptions,
highlightedIndex,
dropdownStyle,
toggleDropdown,
selectItem,
handleBlur,
handleKeyDown
};
}
};
</script>
<style scoped>
.select-container {
position: relative;
width: 100%;
}
.dropdown-menu {
position: fixed;
max-height: 200px;
overflow-y: auto;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 1000;
list-style: none;
padding: 0;
margin: 0;
}
.dropdown-menu li {
padding: 8px 12px;
cursor: pointer;
}
.dropdown-menu li:hover,
.dropdown-menu li.highlighted {
background-color: #f5f5f5;
}
input.error {
border-color: #ff4444;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
这个实现包含了前面讨论的所有优化点:
在实际项目中,我建议进一步封装成可复用的组件,并通过props暴露必要的自定义选项(如动画时长、下拉最大高度等)。如果项目中使用TypeScript,还可以为组件props和emit事件添加类型定义,提升开发体验。