Vue+Element Plus实现自定义日期选择器
作者:王六岁
这篇文章主要为大家详细介绍了如何基于Vue和Element Plus提供的现有组件,设计并实现了一个自定义的日期选择器组件,感兴趣的小伙伴可以参考一下
今天和大家分享一个基于 Vue 的日期选择器组件开发实践。由于 Element Plus 中并没有单独的月份和日期的选择方式,所以我们基于 Vue 和 Element Plus 提供的现有组件,设计并实现了一个自定义的日期选择器组件,支持单独选择月份和日期
先看效果

核心代码解析
1. 弹窗结构
我们使用了 Vue 的 teleport 功能,将弹窗元素直接挂载到 body,避免父级样式干扰:
<template>
<div class="el-month-day-select">
<!-- 绑定输入框 -->
<div ref="inputElement">
<el-input
v-model="value"
placeholder="月/日"
readonly
@focus="onFocus"
@blur="onBlur"
:suffix-icon="Calendar"
/>
</div>
<!-- 使用 teleport 将弹窗渲染到 body -->
<teleport to="body">
<div
v-if="showDropdown"
class="custom-popover"
:style="popoverStyle"
@blur="handleBlur"
tabindex="-1"
>
<div class="picker-wrapper" @wheel.stop>
<div class="picker">
<!-- 月份选择 -->
<el-scrollbar class="scrollbar" @wheel.stop>
<ul class="scroll-list">
<li
v-for="month in months"
:key="month"
ref="monthRefs"
:class="{ selected: month === tempSelectedMonth }"
@click="tempSelectMonth(month)"
>
{{ month.toString().padStart(2, "0") }} 月
</li>
</ul>
</el-scrollbar>
</div>
<div class="picker">
<!-- 天数选择 -->
<el-scrollbar class="scrollbar" @wheel.stop>
<ul class="scroll-list">
<li
v-for="day in days"
:key="day"
ref="dayRefs"
:class="{ selected: day === tempSelectedDay }"
@click="tempSelectDay(day)"
>
{{ day.toString().padStart(2, "0") }} 日
</li>
</ul>
</el-scrollbar>
</div>
</div>
<!-- 按钮操作 -->
<div class="action-buttons">
<el-button size="small" @click="cancel">取消</el-button>
<el-button size="small" type="primary" @click="confirm"
>确定</el-button
>
</div>
</div>
</teleport>
</div>
</template>2. 弹窗定位与样式
为确保弹窗出现在输入框正下方,我们计算了弹窗的绝对位置:
const updatePopoverPosition = () => {
if (inputElement.value) {
const inputRect = inputElement.value.getBoundingClientRect();
popoverTop.value = inputRect.bottom + window.scrollY;
popoverLeft.value = inputRect.left + window.scrollX;
}
};
弹窗样式中,我们加入了一个旋转 45° 的尖角,并添加了阴影,提升视觉层次感:
.custom-popover {
position: absolute;
background: white;
border: 1px solid #ccc;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 10px;
}
.custom-popover::after {
content: '';
position: absolute;
top: -4px;
left: 20px;
width: 16px;
height: 16px;
background: white;
transform: rotate(45deg); /* 旋转尖角 */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10001;
}
3. 交互逻辑
我们通过 focus 和 blur 事件实现弹窗的显示与关闭,同时确保组件内部交互时弹窗不会意外关闭:
const onFocus = () => {
showDropdown.value = true;
nextTick(updatePopoverPosition);
};
const onBlur = () => {
setTimeout(() => {
if (!document.activeElement.closest(".custom-popover")) {
showDropdown.value = false;
}
}, 100);
};
后期优化计划
目前组件默认使用 "MM-DD" 的日期格式。后期优化方向包括:
1.支持自定义日期格式
通过新增 format 属性,允许开发者指定输出格式,例如 MM-DD, DD/MM, MM月DD日 等。
示例代码:
props: {
format: {
type: String,
default: "MM-DD"
}
},
const formatValue = (month, day) => {
return props.format
.replace("MM", month.toString().padStart(2, "0"))
.replace("DD", day.toString().padStart(2, "0"));
};
完整代码
<template>
<div class="el-month-day-select">
<!-- 绑定输入框 -->
<div ref="inputElement">
<el-input
v-model="value"
placeholder="月/日"
readonly
@focus="onFocus"
@blur="onBlur"
:suffix-icon="Calendar"
/>
</div>
<!-- 使用 teleport 将弹窗渲染到 body -->
<teleport to="body">
<div
v-if="showDropdown"
class="custom-popover"
:style="popoverStyle"
@blur="handleBlur"
tabindex="-1"
>
<div class="picker-wrapper" @wheel.stop>
<div class="picker">
<!-- 月份选择 -->
<el-scrollbar class="scrollbar" @wheel.stop>
<ul class="scroll-list">
<li
v-for="month in months"
:key="month"
ref="monthRefs"
:class="{ selected: month === tempSelectedMonth }"
@click="tempSelectMonth(month)"
>
{{ month.toString().padStart(2, "0") }} 月
</li>
</ul>
</el-scrollbar>
</div>
<div class="picker">
<!-- 天数选择 -->
<el-scrollbar class="scrollbar" @wheel.stop>
<ul class="scroll-list">
<li
v-for="day in days"
:key="day"
ref="dayRefs"
:class="{ selected: day === tempSelectedDay }"
@click="tempSelectDay(day)"
>
{{ day.toString().padStart(2, "0") }} 日
</li>
</ul>
</el-scrollbar>
</div>
</div>
<!-- 按钮操作 -->
<div class="action-buttons">
<el-button size="small" @click="cancel">取消</el-button>
<el-button size="small" type="primary" @click="confirm"
>确定</el-button
>
</div>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { Calendar } from "@element-plus/icons-vue";
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const formatValue = (month, day) => {
return `${month.toString().padStart(2, "0")}/${day
.toString()
.padStart(2, "0")}`;
};
const currentYear = new Date().getFullYear();
const showDropdown = ref(false);
const months = Array.from({ length: 12 }, (_, i) => i + 1);
const selectedMonth = ref(1);
const selectedDay = ref(1);
const tempSelectedMonth = ref(1);
const tempSelectedDay = ref(1);
const monthRefs = ref([]);
const dayRefs = ref([]);
const value = ref("");
const days = computed(() => {
const daysInMonth = new Date(
currentYear,
tempSelectedMonth.value,
0
).getDate();
return Array.from({ length: daysInMonth }, (_, i) => i + 1);
});
watch(
() => props.modelValue,
newValue => {
if (newValue) {
const [month, day] = newValue.split("/").map(Number);
selectedMonth.value = month || 1;
selectedDay.value = day || 1;
tempSelectedMonth.value = month || 1;
tempSelectedDay.value = day || 1;
value.value = formatValue(selectedMonth.value, selectedDay.value);
} else {
value.value = "";
}
},
{ immediate: true }
);
const scrollToSelected = (refs, index) => {
nextTick(() => {
const element = refs.value[index];
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "center"
});
}
});
};
const tempSelectMonth = month => {
tempSelectedMonth.value = month;
scrollToSelected(monthRefs, month - 1);
};
const tempSelectDay = day => {
tempSelectedDay.value = day;
scrollToSelected(dayRefs, day - 1);
};
const close = () => {
showDropdown.value = false; // 关闭弹窗
};
const confirm = () => {
selectedMonth.value = tempSelectedMonth.value;
selectedDay.value = tempSelectedDay.value;
const formattedValue = formatValue(selectedMonth.value, selectedDay.value);
emit("update:modelValue", formattedValue);
value.value = formattedValue;
close();
};
const cancel = () => {
close();
};
const onFocus = () => {
if (!showDropdown.value) {
showDropdown.value = true; // 仅在未打开时才打开下拉框
nextTick(() => {
scrollToSelected(monthRefs, tempSelectedMonth.value - 1);
scrollToSelected(dayRefs, tempSelectedDay.value - 1);
});
}
};
const onBlur = () => {
setTimeout(() => {
// 在失去焦点时关闭弹窗,给弹窗内容一些时间渲染
if (!document.activeElement.closest(".custom-popover")) {
close();
}
}, 100); // 延时关闭,避免与其他操作冲突
};
const handleBlur = e => {
// 监听popover的blur事件
const popover = e.target;
setTimeout(() => {
// 如果失去焦点且popover没有被重新激活,关闭弹窗
if (!popover.contains(document.activeElement)) {
close();
}
}, 200);
};
const inputElement = ref(null);
const popoverTop = ref(0);
const popoverLeft = ref(0);
const popoverZIndex = ref(10000);
// 计算弹窗的位置
const updatePopoverPosition = () => {
if (inputElement.value) {
const inputRect = inputElement.value.getBoundingClientRect();
popoverTop.value = inputRect.bottom + window.scrollY + 18;
popoverLeft.value = inputRect.left + window.scrollX;
}
};
// 每次显示弹窗时都更新位置
watch(
() => showDropdown.value,
newValue => {
if (newValue) {
updatePopoverPosition();
}
}
);
// 弹窗样式
const popoverStyle = computed(() => ({
top: `${popoverTop.value}px`,
left: `${popoverLeft.value}px`,
zIndex: popoverZIndex.value
}));
// 销毁
onBeforeUnmount(() => {
value.value = "";
});
</script>
<style scoped>
.custom-popover {
position: absolute;
background: white;
border: 1px solid #ccc;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
width: 300px;
padding: 10px;
border-radius: 4px;
}
.custom-popover::after {
content: "";
position: absolute;
top: -9px; /* 调整位置以匹配旋转后的尖角 */
left: 20px;
width: 16px;
height: 16px;
background: white;
transform: rotate(45deg); /* 旋转 45 度 */
border: 1px solid #e4e7ed;
background: #fff;
border-bottom-color: transparent !important;
border-right-color: transparent !important;
z-index: 10001;
}
.picker-wrapper {
display: flex;
justify-content: space-between;
}
.picker {
width: 48%;
}
.scrollbar {
max-height: 200px;
overflow-y: auto;
}
.scroll-list {
list-style-type: none;
padding: 0;
margin: 0;
}
.scroll-list li {
padding: 5px;
cursor: pointer;
}
.scroll-list li.selected {
background-color: #e6f7ff;
}
.action-buttons {
margin-top: 10px;
display: flex;
justify-content: space-between;
}
.el-month-day-select {
position: relative;
display: inline-block;
}
.picker-wrapper {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.picker {
flex: 1;
width: 48%;
height: 200px;
overflow: hidden;
border: 1px solid #dcdfe6;
border-radius: 4px;
position: relative;
}
.scrollbar {
height: 100%;
overflow: hidden;
}
.scroll-list {
list-style: none;
padding: 0;
margin: 0;
text-align: center;
}
.scroll-list li {
padding: 10px 0;
margin-right: 10px;
cursor: pointer;
transition: all 0.3s ease;
}
.scroll-list li.selected {
color: #409eff;
font-weight: bold;
transform: scale(1.1);
}
.action-buttons {
display: flex;
justify-content: flex-end;
}
</style>到此这篇关于Vue+Element Plus实现自定义日期选择器的文章就介绍到这了,更多相关Element Plus日期选择器内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
