vue3用input自定义下拉框,广播事件
时间: 2025-07-20 20:17:55 AIGC 浏览: 36
<think>我们将创建一个自定义下拉框组件,它使用原生的input元素并扩展其功能,同时支持通过广播事件(使用Vue3的emits)与父组件通信。
组件功能:
1. 点击输入框显示下拉选项
2. 选择选项后更新输入框的值并隐藏下拉框
3. 支持键盘导航(上下箭头选择,回车确认)
4. 点击外部区域关闭下拉框
5. 通过v-model绑定值
6. 自定义事件:输入变化、选项选择
步骤:
1. 创建组件CustomSelect.vue
2. 使用input元素作为输入框,并添加一个下拉列表(ul)
3. 实现点击输入框显示/隐藏下拉列表
4. 实现选择选项后的更新和事件触发
5. 实现键盘导航
6. 实现点击外部关闭
7. 使用v-model绑定值
注意:为了简化,我们假设选项是字符串数组,实际中可以是对象数组,通过prop传递。
代码实现:</think>下面是一个使用 Vue 3 实现的自定义下拉框组件,支持键盘导航、外部点击关闭和事件广播功能:
```vue
<!-- CustomDropdown.vue -->
<template>
<div class="custom-dropdown" ref="dropdownContainer">
<!-- 输入框区域 -->
<div
class="dropdown-input"
@click="toggleDropdown"
@keydown.down="handleKeyDown"
@keydown.up="handleKeyUp"
@keydown.enter="selectOption"
tabindex="0"
>
<input
type="text"
v-model="inputValue"
:placeholder="placeholder"
@input="handleInput"
readonly
>
<span class="dropdown-icon">
<svg :class="{ 'rotate-180': showOptions }" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5z"/>
</svg>
</span>
</div>
<!-- 下拉选项区域 -->
<transition name="dropdown">
<ul
v-show="showOptions"
class="dropdown-options"
ref="optionsList"
>
<li
v-for="(option, index) in filteredOptions"
:key="index"
:class="{
'selected': index === selectedIndex,
'disabled': option.disabled
}"
@click="selectOption(option)"
@mouseenter="hoverIndex = index"
>
<slot name="option" :option="option">
{{ option.label || option }}
</slot>
</li>
<li v-if="filteredOptions.length === 0" class="no-results">
{{ noResultsText }}
</li>
</ul>
</transition>
</div>
</template>
<script>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
export default {
name: 'CustomDropdown',
props: {
options: {
type: Array,
required: true,
default: () => []
},
modelValue: {
type: [String, Number, Object],
default: null
},
placeholder: {
type: String,
default: '请选择'
},
filterable: {
type: Boolean,
default: false
},
noResultsText: {
type: String,
default: '无匹配项'
}
},
emits: [
'update:modelValue',
'select', // 选择选项时触发
'dropdown-open', // 下拉框打开时触发
'dropdown-close', // 下拉框关闭时触发
'filter-change' // 过滤条件变化时触发
],
setup(props, { emit }) {
const showOptions = ref(false);
const inputValue = ref('');
const selectedIndex = ref(-1);
const hoverIndex = ref(-1);
const dropdownContainer = ref(null);
const optionsList = ref(null);
// 计算过滤后的选项
const filteredOptions = computed(() => {
if (!props.filterable || !inputValue.value) {
return props.options;
}
const filterValue = inputValue.value.toLowerCase();
emit('filter-change', filterValue);
return props.options.filter(option => {
const label = typeof option === 'object' ? option.label : option;
return label.toLowerCase().includes(filterValue);
});
});
// 监听模型值变化
watch(() => props.modelValue, (newVal) => {
if (!newVal) {
inputValue.value = '';
return;
}
const selectedOption = props.options.find(opt => {
if (typeof opt === 'object') {
return opt.value === newVal || opt === newVal;
}
return opt === newVal;
});
if (selectedOption) {
inputValue.value = typeof selectedOption === 'object'
? selectedOption.label
: selectedOption;
}
}, { immediate: true });
// 切换下拉框显示状态
const toggleDropdown = () => {
showOptions.value = !showOptions.value;
if (showOptions.value) {
emit('dropdown-open');
// 打开时重置选择索引
selectedIndex.value = -1;
hoverIndex.value = -1;
} else {
emit('dropdown-close');
}
};
// 处理键盘下键
const handleKeyDown = (e) => {
e.preventDefault();
if (!showOptions.value) {
toggleDropdown();
return;
}
if (filteredOptions.value.length === 0) return;
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
scrollToOption(selectedIndex.value);
};
// 处理键盘上键
const handleKeyUp = (e) => {
e.preventDefault();
if (!showOptions.value || filteredOptions.value.length === 0) return;
selectedIndex.value = selectedIndex.value <= 0
? filteredOptions.value.length - 1
: selectedIndex.value - 1;
scrollToOption(selectedIndex.value);
};
// 滚动到选中的选项
const scrollToOption = (index) => {
if (optionsList.value && optionsList.value.children[index]) {
optionsList.value.children[index].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
};
// 选择选项
const selectOption = (option) => {
if (!option && selectedIndex.value >= 0) {
option = filteredOptions.value[selectedIndex.value];
}
if (!option || (typeof option === 'object' && option.disabled)) return;
// 更新输入框值
inputValue.value = typeof option === 'object' ? option.label : option;
// 更新模型值
const value = typeof option === 'object' ? option.value : option;
emit('update:modelValue', value);
// 广播选择事件
emit('select', {
option,
value,
label: inputValue.value
});
// 关闭下拉框
showOptions.value = false;
emit('dropdown-close');
};
// 处理输入变化
const handleInput = (e) => {
if (props.filterable) {
inputValue.value = e.target.value;
showOptions.value = true;
}
};
// 点击外部关闭下拉框
const handleClickOutside = (event) => {
if (dropdownContainer.value && !dropdownContainer.value.contains(event.target)) {
showOptions.value = false;
emit('dropdown-close');
}
};
// 生命周期钩子
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
return {
showOptions,
inputValue,
selectedIndex,
hoverIndex,
dropdownContainer,
optionsList,
filteredOptions,
toggleDropdown,
handleKeyDown,
handleKeyUp,
selectOption,
handleInput
};
}
};
</script>
<style scoped>
.custom-dropdown {
position: relative;
width: 100%;
max-width: 300px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
.dropdown-input {
position: relative;
display: flex;
align-items: center;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px 15px;
background: white;
cursor: pointer;
transition: border-color 0.3s, box-shadow 0.3s;
}
.dropdown-input:hover {
border-color: #c0c4cc;
}
.dropdown-input:focus-within {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
outline: none;
}
.dropdown-input input {
flex: 1;
border: none;
padding: 0;
background: transparent;
font-size: 14px;
color: #606266;
cursor: pointer;
}
.dropdown-input input:focus {
outline: none;
}
.dropdown-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #c0c4cc;
transition: transform 0.3s;
}
.dropdown-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
transition: transform 0.3s;
}
.rotate-180 {
transform: rotate(180deg);
}
.dropdown-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
margin: 5px 0 0 0;
padding: 6px 0;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border: 1px solid #e4e7ed;
z-index: 1000;
list-style: none;
}
.dropdown-options li {
padding: 8px 20px;
font-size: 14px;
color: #606266;
cursor: pointer;
transition: background-color 0.3s;
}
.dropdown-options li:hover:not(.disabled):not(.no-results) {
background-color: #f5f7fa;
}
.dropdown-options li.selected {
background-color: #f0f7ff;
color: #409eff;
font-weight: 600;
}
.dropdown-options li.disabled {
color: #c0c4cc;
cursor: not-allowed;
background: transparent;
}
.dropdown-options .no-results {
color: #c0c4cc;
text-align: center;
cursor: default;
}
/* 下拉动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.3s ease;
transform-origin: top center;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scaleY(0.8);
}
</style>
```
### 核心功能与实现原理
1. **双向数据绑定**
- 使用 `v-model` 实现组件与父级的数据同步
- 通过 `emit('update:modelValue')` 更新父级绑定值
2. **事件广播系统**
```javascript
emits: [
'update:modelValue',
'select', // 选择选项时触发
'dropdown-open', // 下拉框打开时触发
'dropdown-close', // 下拉框关闭时触发
'filter-change' // 过滤条件变化时触发
]
```
- 使用 Vue 3 的 `emits` 选项明确定义可广播事件
- 在关键操作点触发对应事件(如选择、打开/关闭等)
3. **键盘导航支持**
- `@keydown.down`:向下导航选项
- `@keydown.up`:向上导航选项
- `@keydown.enter`:确认选择
- 自动滚动到当前选中项
4. **点击外部关闭**
```javascript
const handleClickOutside = (event) => {
if (dropdownContainer.value &&
!dropdownContainer.value.contains(event.target)) {
showOptions.value = false;
emit('dropdown-close');
}
};
```
- 使用事件委托监听文档点击
- 判断点击目标是否在组件容器外
5. **选项过滤功能**
```javascript
const filteredOptions = computed(() => {
if (!props.filterable || !inputValue.value) {
return props.options;
}
// 过滤逻辑...
});
```
- 根据输入内容动态过滤选项
- 触发 `filter-change` 事件通知父组件
6. **动画效果**
- 使用 Vue Transition 实现下拉动画
- 添加箭头旋转动画增强交互感
### 使用示例
```vue
<template>
<div>
<CustomDropdown
v-model="selectedValue"
:options="options"
placeholder="选择水果"
filterable
@select="handleSelect"
@dropdown-open="handleDropdownOpen"
>
<!-- 自定义选项模板 -->
<template #option="{ option }">
<div style="display: flex; align-items: center; gap: 8px">
<span v-if="option.icon" class="option-icon">{{ option.icon }}</span>
<span>{{ option.label }}</span>
</div>
</template>
</CustomDropdown>
<p>当前选择: {{ selectedValue }}</p>
</div>
</template>
<script>
import CustomDropdown from './CustomDropdown.vue';
export default {
components: { CustomDropdown },
data() {
return {
selectedValue: null,
options: [
{ value: 'apple', label: '苹果', icon: '🍎' },
{ value: 'banana', label: '香蕉', icon: '🍌' },
{ value: 'orange', label: '橙子', icon: '🍊' },
{ value: 'grape', label: '葡萄', icon: '🍇' },
{ value: 'watermelon', label: '西瓜', icon: '🍉' }
]
};
},
methods: {
handleSelect(payload) {
console.log('选中选项:', payload);
},
handleDropdownOpen() {
console.log('下拉框已打开');
}
}
};
</script>
```
### 最佳实践建议
1. **无障碍支持**
- 添加 `aria-*` 属性增强屏幕阅读器支持
- 实现完整的键盘导航(Tab、Esc 等)
2. **性能优化**
- 对于大型选项列表,使用虚拟滚动
- 防抖处理过滤输入
3. **自定义扩展**
- 通过插槽支持自定义选项模板
- 添加分组选项支持
- 实现多选功能
4. **主题定制**
- 使用 CSS 变量实现主题切换
```css
:root {
--dropdown-primary: #409eff;
--dropdown-bg: #ffffff;
--dropdown-border: #dcdfe6;
}
```
阅读全文
相关推荐



















