日常学习开发记录-select组件(2)
- 第二阶段:增强功能
给现有select组件新增功能
第二阶段:增强功能
- 键盘操作支持
- 支持键盘上下箭头选择选项
- 支持回车键确认选择
- 支持Esc键关闭下拉菜单
<template>
<div
:class="['my-select', { 'is-disabled': disabled }]"
@click.stop="toggleDropdown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
v-click-outside="closeDropdown"
tabindex="0"
@keydown="handleKeydown"
ref="select"
>
<!-- 选择器触发器 -->
<div class="my-select__trigger">
<span v-if="!currentValue && !multiple" class="my-select__placeholder">
{{ placeholder }}
</span>
<span v-else-if="!multiple" class="my-select__label">
{{ getSelectedLabel() }}
</span>
<div v-else class="my-select__tags">
<span
v-for="item in selected"
:key="typeof item === 'object' ? item.value : item"
class="my-select__tag"
>
{{ typeof item === 'object' ? item.label : item }}
<i class="my-select__tag-close" @click.stop="removeTag(item)">×</i>
</span>
</div>
<i
v-if="clearable && currentValue && !visible && hover"
class="my-select__clear"
@click.stop="clearSelection"
>
×
</i>
<i v-else class="my-select__arrow" :class="{ 'is-reverse': visible }"></i>
</div>
<!-- 下拉菜单 -->
<div v-show="visible" class="my-select__dropdown">
<div
v-for="(item, index) in options"
:key="typeof item === 'object' ? item.value : item"
class="my-select__option"
:class="{
'is-selected': isSelected(item),
'is-disabled': item.disabled,
'is-highlighted': highlightedIndex === index,
}"
@click.stop="handleOptionClick(item)"
@mouseenter="highlightedIndex = index"
>
<slot name="option" :item="item" :index="index">
{{ typeof item === 'object' ? item.label : item }}
</slot>
</div>
<div v-if="options.length === 0" class="my-select__empty">
<slot name="empty">无数据</slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'MySelect',
directives: {
clickOutside: {
bind(el, binding) {
el.clickOutsideEvent = event => {
if (!(el == event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el.clickOutsideEvent)
},
unbind(el) {
document.removeEventListener('click', el.clickOutsideEvent)
},
},
},
props: {
value: {
type: [String, Array],
default: '',
},
options: {
type: Array,
default: () => [],
},
multiple: {
type: Boolean,
default: false,
},
clearable: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '请选择',
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
visible: false,
currentValue: this.value,
selected: [],
hover: false,
highlightedIndex: -1, // 当前高亮的选项索引
}
},
watch: {
value: {
handler(newVal) {
this.currentValue = newVal
if (this.multiple) {
this.selected = Array.isArray(newVal) ? [...newVal] : []
}
},
immediate: true,
},
visible(val) {
if (val) {
// 当下拉菜单打开时,重置高亮索引或设置为当前选中项
this.initHighlightIndex()
} else {
// 关闭时重置高亮索引
this.highlightedIndex = -1
}
},
options() {
// 当选项变化时,重置高亮索引
if (this.visible) {
this.initHighlightIndex()
}
},
},
methods: {
handleMouseEnter() {
this.hover = true
},
handleMouseLeave() {
this.hover = false
},
isSelected(item) {
return this.multiple ? this.selected.includes(item) : this.currentValue === item.value
},
toggleDropdown() {
if (this.disabled) return
this.visible = !this.visible
if (this.visible) {
// 切换后聚焦以便捕获键盘事件
this.$nextTick(() => {
this.$refs.select.focus()
})
}
},
closeDropdown() {
this.visible = false
},
getSelectedLabel() {
return (
this.options.find(item => item.value === this.currentValue)?.label || this.placeholder
)
},
handleOptionClick(item) {
if (item.disabled) return
if (this.multiple) {
if (this.selected.includes(item)) {
this.selected = this.selected.filter(i => i !== item)
} else {
this.selected.push(item)
}
this.$emit('input', this.selected)
this.$emit('change', this.selected)
} else {
this.currentValue = item.value
this.$emit('input', item.value)
this.$emit('change', item.value)
this.closeDropdown()
}
},
clearSelection(event) {
event.stopPropagation()
this.currentValue = ''
this.selected = []
this.$emit('input', this.multiple ? [] : '')
this.$emit('clear')
},
removeTag(item) {
this.selected = this.selected.filter(i => i !== item)
this.$emit('input', this.selected)
this.$emit('remove-tag', item)
},
// 键盘事件处理
handleKeydown(event) {
if (this.disabled) return
// 只有在下拉菜单打开或按下方向键、回车键和ESC键时才处理
const keyCode = event.keyCode
if (this.visible) {
// 下拉菜单已打开
switch (keyCode) {
case 38: // 上箭头
event.preventDefault()
this.navigateOptions('prev')
break
case 40: // 下箭头
event.preventDefault()
this.navigateOptions('next')
break
case 13: // 回车键
event.preventDefault()
if (this.highlightedIndex > -1 && this.options[this.highlightedIndex]) {
this.handleOptionClick(this.options[this.highlightedIndex])
}
break
case 27: // ESC键
event.preventDefault()
this.closeDropdown()
break
}
} else {
// 下拉菜单未打开
switch (keyCode) {
case 38: // 上箭头
case 40: // 下箭头
event.preventDefault()
this.toggleDropdown()
break
case 13: // 回车键
event.preventDefault()
this.toggleDropdown()
break
}
}
},
// 导航选项
navigateOptions(direction) {
if (this.options.length === 0) return
let newIndex
if (direction === 'next') {
// 如果当前高亮选项是最后一个,则跳转到第一个
if (this.highlightedIndex >= this.options.length - 1) {
newIndex = 0
} else {
newIndex = this.highlightedIndex + 1
}
} else if (direction === 'prev') {
// 如果当前高亮选项是第一个,则跳转到最后一个
if (this.highlightedIndex <= 0) {
newIndex = this.options.length - 1
} else {
newIndex = this.highlightedIndex - 1
}
}
// 跳过禁用的选项
const numOptions = this.options.length
let counter = 0
// 如果当前高亮选项是禁用的,则继续跳转
while (
counter++ < numOptions &&
this.options[newIndex] &&
this.options[newIndex].disabled
) {
if (direction === 'next') {
newIndex = newIndex >= this.options.length - 1 ? 0 : newIndex + 1
} else {
newIndex = newIndex <= 0 ? this.options.length - 1 : newIndex - 1
}
}
this.highlightedIndex = newIndex
this.scrollToOption()
},
// 滚动到当前高亮的选项
scrollToOption() {
this.$nextTick(() => {
const dropdown = this.$el.querySelector('.my-select__dropdown')
if (!dropdown) return
const options = dropdown.querySelectorAll('.my-select__option')
if (this.highlightedIndex < 0 || !options[this.highlightedIndex]) return
const highlighted = options[this.highlightedIndex]
const scrollTop = dropdown.scrollTop
const offsetTop = highlighted.offsetTop
// 如果高亮选项在可视区域上方,则滚动到高亮选项上方
if (offsetTop < scrollTop) {
dropdown.scrollTop = offsetTop
} else if (offsetTop > scrollTop + dropdown.clientHeight - highlighted.offsetHeight) {
// 如果高亮选项在可视区域下方,则滚动到高亮选项下方
dropdown.scrollTop = offsetTop - dropdown.clientHeight + highlighted.offsetHeight
}
})
},
// 初始化高亮索引
initHighlightIndex() {
this.highlightedIndex = -1
// 尝试高亮当前选中项
if (!this.multiple && this.currentValue) {
const selectedIndex = this.options.findIndex(item => item.value === this.currentValue)
if (selectedIndex > -1) {
this.highlightedIndex = selectedIndex
this.scrollToOption()
return
}
}
// 否则高亮第一个非禁用选项
for (let i = 0; i < this.options.length; i++) {
if (!this.options[i].disabled) {
this.highlightedIndex = i
this.scrollToOption()
break
}
}
},
},
}
</script>
<style lang="scss" scoped>
.my-select {
position: relative;
display: inline-block;
width: 240px;
font-size: 14px;
cursor: pointer;
outline: none; // 移除默认的焦点轮廓,可以添加自定义样式
&:focus {
.my-select__trigger {
border-color: #409eff;
outline: 0;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
}
&.is-disabled {
.my-select__trigger {
background-color: rgb(245, 247, 250);
color: rgb(192, 196, 204);
cursor: not-allowed;
border-color: rgb(228, 231, 237);
}
}
&__trigger {
display: flex;
align-items: center;
background-color: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 35px 0 15px;
min-height: 40px;
line-height: 40px;
position: relative;
transition: border-color 0.2s;
box-sizing: border-box;
&:hover {
border-color: #c0c4cc;
}
}
&__placeholder {
color: #909399;
}
&__arrow {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.3s;
width: 0;
height: 0;
border-style: solid;
border-width: 5px 5px 0 5px;
border-color: #c0c4cc transparent transparent transparent;
&.is-reverse {
transform: translateY(-50%) rotate(180deg);
}
}
&__clear {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
color: #c0c4cc;
font-size: 14px;
&:hover {
color: #909399;
}
}
&__dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 5px;
background-color: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
box-sizing: border-box;
z-index: 1000;
width: 100%;
max-height: 274px;
overflow-y: auto;
}
&__option {
padding: 0 20px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 34px;
line-height: 34px;
box-sizing: border-box;
&:hover {
background-color: #f5f7fa;
}
&.is-selected {
color: #409eff;
font-weight: 700;
}
&.is-highlighted {
background-color: #f5f7fa;
}
&.is-disabled {
color: #c0c4cc;
cursor: not-allowed;
}
}
&__empty {
padding: 10px 0;
text-align: center;
color: #909399;
}
&__tags {
display: flex;
flex-wrap: wrap;
line-height: normal;
max-width: 100%;
overflow: hidden;
}
&__tag {
display: inline-flex;
align-items: center;
max-width: 100%;
margin: 2px 0 2px 6px;
padding: 0 5px 0 10px;
background-color: #f0f2f5;
border-radius: 4px;
height: 24px;
line-height: 24px;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
}
&__tag-close {
margin-left: 5px;
color: #909399;
font-size: 12px;
cursor: pointer;
&:hover {
color: #606266;
}
}
}
</style>
箭头键导航:
当下拉菜单关闭时,按上下箭头键可以打开下拉菜单
当下拉菜单打开时,按上下箭头键可以在选项中导航
导航会自动跳过禁用的选项
当到达列表顶部或底部时会循环到另一端
回车键确认:
当下拉菜单关闭时,按回车键会打开下拉菜单
当下拉菜单打开时,按回车键会选中当前高亮的选项
Esc键关闭:
按Esc键会关闭下拉菜单
此外,我还添加了以下增强功能:
高亮状态:
添加了视觉高亮效果,显示当前键盘导航位置
鼠标悬停时也会更新高亮状态
滚动同步:
当使用键盘导航时,会自动滚动到当前高亮的选项位置
确保高亮选项始终在可视区域内
焦点样式:
添加了组件获得焦点时的视觉反馈
使用蓝色边框和轻微阴影提示当前可以使用键盘操作
以上修改使Select组件的可访问性大大提高,既提供了键盘操作支持,又保留了原有的鼠标操作功能。