Vue+Element Plus實(shí)現(xiàn)自定義日期選擇器
今天和大家分享一個(gè)基于 Vue 的日期選擇器組件開發(fā)實(shí)踐。由于 Element Plus 中并沒有單獨(dú)的月份和日期的選擇方式,所以我們基于 Vue 和 Element Plus 提供的現(xiàn)有組件,設(shè)計(jì)并實(shí)現(xiàn)了一個(gè)自定義的日期選擇器組件,支持單獨(dú)選擇月份和日期
先看效果
核心代碼解析
1. 彈窗結(jié)構(gòu)
我們使用了 Vue 的 teleport
功能,將彈窗元素直接掛載到 body
,避免父級(jí)樣式干擾:
<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)在輸入框正下方,我們計(jì)算了彈窗的絕對(duì)位置:
const updatePopoverPosition = () => { if (inputElement.value) { const inputRect = inputElement.value.getBoundingClientRect(); popoverTop.value = inputRect.bottom + window.scrollY; popoverLeft.value = inputRect.left + window.scrollX; } };
彈窗樣式中,我們加入了一個(gè)旋轉(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. 交互邏輯
我們通過(guò) focus
和 blur
事件實(shí)現(xiàn)彈窗的顯示與關(guān)閉,同時(shí)確保組件內(nèi)部交互時(shí)彈窗不會(huì)意外關(guān)閉:
const onFocus = () => { showDropdown.value = true; nextTick(updatePopoverPosition); }; const onBlur = () => { setTimeout(() => { if (!document.activeElement.closest(".custom-popover")) { showDropdown.value = false; } }, 100); };
后期優(yōu)化計(jì)劃
目前組件默認(rèn)使用 "MM-DD"
的日期格式。后期優(yōu)化方向包括:
1.支持自定義日期格式
通過(guò)新增 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; // 關(guān)閉彈窗 }; 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; // 僅在未打開時(shí)才打開下拉框 nextTick(() => { scrollToSelected(monthRefs, tempSelectedMonth.value - 1); scrollToSelected(dayRefs, tempSelectedDay.value - 1); }); } }; const onBlur = () => { setTimeout(() => { // 在失去焦點(diǎn)時(shí)關(guān)閉彈窗,給彈窗內(nèi)容一些時(shí)間渲染 if (!document.activeElement.closest(".custom-popover")) { close(); } }, 100); // 延時(shí)關(guān)閉,避免與其他操作沖突 }; const handleBlur = e => { // 監(jiān)聽popover的blur事件 const popover = e.target; setTimeout(() => { // 如果失去焦點(diǎn)且popover沒有被重新激活,關(guān)閉彈窗 if (!popover.contains(document.activeElement)) { close(); } }, 200); }; const inputElement = ref(null); const popoverTop = ref(0); const popoverLeft = ref(0); const popoverZIndex = ref(10000); // 計(jì)算彈窗的位置 const updatePopoverPosition = () => { if (inputElement.value) { const inputRect = inputElement.value.getBoundingClientRect(); popoverTop.value = inputRect.bottom + window.scrollY + 18; popoverLeft.value = inputRect.left + window.scrollX; } }; // 每次顯示彈窗時(shí)都更新位置 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>
到此這篇關(guān)于Vue+Element Plus實(shí)現(xiàn)自定義日期選擇器的文章就介紹到這了,更多相關(guān)Element Plus日期選擇器內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
vue component 中引入less文件報(bào)錯(cuò) Module build failed
這篇文章主要介紹了vue component 中引入less文件報(bào)錯(cuò) Module build failed的解決方法,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-04-04Vue簡(jiǎn)明介紹配置對(duì)象的配置選項(xiàng)
我們知道每一個(gè)vue項(xiàng)目應(yīng)用都是通過(guò)vue的構(gòu)造函數(shù)進(jìn)行創(chuàng)建一個(gè)新的vue項(xiàng)目的。創(chuàng)建vue實(shí)例的配置對(duì)象,可以包括一下屬性選項(xiàng),比如:data、methods、watch、template等等,每一個(gè)選項(xiàng)都有不同的功能,大家可以根據(jù)自己的需求選擇不同的配置2022-08-08vue中實(shí)現(xiàn)子組件相互切換且數(shù)據(jù)不丟失的策略詳解
項(xiàng)目為數(shù)據(jù)報(bào)表,但是一個(gè)父頁(yè)面中有很多的子頁(yè)面,而且子頁(yè)面中不是相互關(guān)聯(lián),但是數(shù)據(jù)又有聯(lián)系,所以本文給大家介紹了vue中如何實(shí)現(xiàn)子組件相互切換,而且數(shù)據(jù)不會(huì)丟失,并有詳細(xì)的代碼供大家參考,需要的朋友可以參考下2024-03-03vue中實(shí)現(xiàn)點(diǎn)擊變成全屏的多種方法
這篇文章主要介紹了vue中實(shí)現(xiàn)點(diǎn)擊變成全屏的多種方法,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-09-09Vue mixin實(shí)現(xiàn)組件功能復(fù)用示例詳解
這篇文章主要為大家介紹了Vue mixin實(shí)現(xiàn)組件功能復(fù)用示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-10-10Vue開發(fā)實(shí)現(xiàn)吸頂效果的示例代碼
這篇文章主要介紹了Vue開發(fā)實(shí)現(xiàn)吸頂效果的示例代碼,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-08-08Vue3實(shí)現(xiàn)動(dòng)態(tài)路由與手動(dòng)導(dǎo)航
這篇文章主要為大家詳細(xì)介紹了如何在?Vue?3?中實(shí)現(xiàn)動(dòng)態(tài)路由注冊(cè)和手動(dòng)導(dǎo)航,確保用戶訪問的頁(yè)面與權(quán)限對(duì)應(yīng),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2024-12-12Element-Plus Select組件實(shí)現(xiàn)滾動(dòng)分頁(yè)加載功能
Element-Plus的select組件并沒有自帶滾動(dòng)分頁(yè)加載的功能,其雖然提供了自定義下拉菜單的底部的方式可以自定義上一頁(yè)及下一頁(yè)操作按鈕的方式進(jìn)行分頁(yè)加載切換,這篇文章主要介紹了Element-Plus Select組件實(shí)現(xiàn)滾動(dòng)分頁(yè)加載功能,需要的朋友可以參考下2024-03-03