Vue+Element Plus實現(xiàn)自定義日期選擇器
今天和大家分享一個基于 Vue 的日期選擇器組件開發(fā)實踐。由于 Element Plus 中并沒有單獨的月份和日期的選擇方式,所以我們基于 Vue 和 Element Plus 提供的現(xiàn)有組件,設計并實現(xiàn)了一個自定義的日期選擇器組件,支持單獨選擇月份和日期
先看效果

核心代碼解析
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">
<!-- 天數(shù)選擇 -->
<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. 彈窗定位與樣式
為確保彈窗出現(xiàn)在輸入框正下方,我們計算了彈窗的絕對位置:
const updatePopoverPosition = () => {
if (inputElement.value) {
const inputRect = inputElement.value.getBoundingClientRect();
popoverTop.value = inputRect.bottom + window.scrollY;
popoverLeft.value = inputRect.left + window.scrollX;
}
};
彈窗樣式中,我們加入了一個旋轉(zhuǎn) 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); /* 旋轉(zhuǎn)尖角 */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10001;
}
3. 交互邏輯
我們通過 focus 和 blur 事件實現(xiàn)彈窗的顯示與關閉,同時確保組件內(nèi)部交互時彈窗不會意外關閉:
const onFocus = () => {
showDropdown.value = true;
nextTick(updatePopoverPosition);
};
const onBlur = () => {
setTimeout(() => {
if (!document.activeElement.closest(".custom-popover")) {
showDropdown.value = false;
}
}, 100);
};
后期優(yōu)化計劃
目前組件默認使用 "MM-DD" 的日期格式。后期優(yōu)化方向包括:
1.支持自定義日期格式
通過新增 format 屬性,允許開發(fā)者指定輸出格式,例如 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">
<!-- 天數(shù)選擇 -->
<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(() => {
// 在失去焦點時關閉彈窗,給彈窗內(nèi)容一些時間渲染
if (!document.activeElement.closest(".custom-popover")) {
close();
}
}, 100); // 延時關閉,避免與其他操作沖突
};
const handleBlur = e => {
// 監(jiān)聽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; /* 調(diào)整位置以匹配旋轉(zhuǎn)后的尖角 */
left: 20px;
width: 16px;
height: 16px;
background: white;
transform: rotate(45deg); /* 旋轉(zhuǎn) 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實現(xiàn)自定義日期選擇器的文章就介紹到這了,更多相關Element Plus日期選擇器內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
vue component 中引入less文件報錯 Module build failed
這篇文章主要介紹了vue component 中引入less文件報錯 Module build failed的解決方法,非常不錯,具有一定的參考借鑒價值,需要的朋友可以參考下2019-04-04
vue中實現(xiàn)子組件相互切換且數(shù)據(jù)不丟失的策略詳解
項目為數(shù)據(jù)報表,但是一個父頁面中有很多的子頁面,而且子頁面中不是相互關聯(lián),但是數(shù)據(jù)又有聯(lián)系,所以本文給大家介紹了vue中如何實現(xiàn)子組件相互切換,而且數(shù)據(jù)不會丟失,并有詳細的代碼供大家參考,需要的朋友可以參考下2024-03-03
Element-Plus Select組件實現(xiàn)滾動分頁加載功能
Element-Plus的select組件并沒有自帶滾動分頁加載的功能,其雖然提供了自定義下拉菜單的底部的方式可以自定義上一頁及下一頁操作按鈕的方式進行分頁加載切換,這篇文章主要介紹了Element-Plus Select組件實現(xiàn)滾動分頁加載功能,需要的朋友可以參考下2024-03-03

