Vue 3 NutUI日歷組件:展開/收縮功能的實(shí)現(xiàn)邏輯詳解
?? 概述
本文詳細(xì)介紹了一個(gè)基于 Vue 3 + TypeScript + Taro + NutUI的智能日歷組件中展開/收縮功能的實(shí)現(xiàn)邏輯。該組件支持兩種顯示模式:展開模式顯示整月,收縮模式只顯示本周,通過(guò)平滑的動(dòng)畫效果提供良好的用戶體驗(yàn)。
? 核心功能
雙模式切換機(jī)制
- 展開模式: 顯示整月日歷,用戶可以查看和選擇整個(gè)月的日期
- 收縮模式: 只顯示當(dāng)前周,節(jié)省界面空間,專注于本周安排
?? 技術(shù)實(shí)現(xiàn)
1. 狀態(tài)管理
// 控制日歷展開/收縮狀態(tài)
const isExpanded = ref(false)
// 所有日期元素的總高度(展開模式)
const allDaysHeight = ref(0)
// 單行日期元素的高度(收縮模式)
const oneDayHeight = ref(60)
2. 日期范圍計(jì)算邏輯
獲取本周范圍(收縮模式)
function getWeekRange(): DateRange {
const today = new Date()
// 計(jì)算本周日的日期(一周的開始)
// getDay() 返回 0-6,0 表示周日,需要特殊處理
const dayOfWeek = today.getDay() === 0 ? 7 : today.getDay()
const firstDay = new Date(today)
firstDay.setDate(today.getDate() - dayOfWeek) // 回退到本周日
firstDay.setHours(0, 0, 0, 0) // 設(shè)置為當(dāng)天開始時(shí)間
// 計(jì)算本周六的日期(一周的結(jié)束)
const lastDay = new Date(firstDay)
lastDay.setDate(firstDay.getDate() + 6) // 前進(jìn)6天到周六
lastDay.setHours(23, 59, 59, 999) // 設(shè)置為當(dāng)天結(jié)束時(shí)間
return { firstDay, lastDay }
}
獲取本月范圍(展開模式)
function getMonthRange(): DateRange {
const today = new Date()
const month = today.getMonth()
const year = today.getFullYear()
// 本月第一天
const firstDay = new Date(year, month, 1)
// 本月最后一天(通過(guò)設(shè)置下月第0天實(shí)現(xiàn))
const lastDay = new Date(year, month + 1, 0)
return { firstDay, lastDay }
}
3. 智能日期禁用邏輯
const disableDay = (day: CalendarCardDay): boolean => {
// 如果有自定義禁用規(guī)則,優(yōu)先使用
if (props.customDisableDay) {
return props.customDisableDay(day)
}
const { firstDay, lastDay } = currentDateRange.value
const current = new Date(day.year, day.month - 1, day.date)
return current < firstDay || current > lastDay
}
4. 核心展開/收縮實(shí)現(xiàn)
DOM 操作函數(shù)
const toggleCalendarDays = () => {
nextTick(() => {
// 使用緩存的 DOM 查詢結(jié)果
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found')
return
}
try {
if (isExpanded.value) {
// 展開模式:顯示所有日期
calendarDays.style.height = `${allDaysHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 恢復(fù)所有日期的顯示
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
dayElement.style.display = ''
})
} else {
// 收縮模式:只顯示當(dāng)前周
calendarDays.style.height = `${oneDayHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 隱藏非當(dāng)前周的日期(通過(guò)禁用狀態(tài)判斷)
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
if (dayElement.classList.contains('disabled')) {
dayElement.style.display = 'none'
} else {
dayElement.style.display = ''
}
})
}
} catch (error) {
console.error('Error toggling calendar days:', error)
}
})
}
安全 DOM 查詢
const safeQuerySelector = (selector: string, index: number = 0): HTMLElement | null => {
try {
const elements = document.querySelectorAll(selector)
return elements[index] as HTMLElement || null
} catch (error) {
console.warn(`Failed to query selector: ${selector}`, error)
return null
}
}
5. 用戶交互處理
防抖優(yōu)化
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number): T => {
return ((...args: any[]) => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => fn(...args), delay)
}) as T
}
const handleToggleMode = debounce(() => {
if (isLoading.value) return
isLoading.value = true
try {
toggleMode()
} finally {
// 延遲重置加載狀態(tài),確保動(dòng)畫完成
setTimeout(() => {
isLoading.value = false
}, 300)
}
}, 100)
切換邏輯
const toggleMode = () => {
// 切換展開狀態(tài)
isExpanded.value = !isExpanded.value
// 觸發(fā)事件
emit('toggle', isExpanded.value)
// 控制日歷日期元素的展開/收縮
toggleCalendarDays()
}
6. 初始化邏輯
const initializeCalendarSizes = async () => {
try {
await nextTick()
// 獲取日期容器元素
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found during initialization')
return
}
// 獲取展開模式下的總高度
const height = calendarDays.getBoundingClientRect().height
allDaysHeight.value = height
// 獲取單個(gè)日期元素的高度
const oneDay = safeQuerySelector('.nut-calendarcard-day', 0)
if (oneDay) {
oneDayHeight.value = oneDay.getBoundingClientRect().height
}
// 初始化日歷的展開/收縮狀態(tài)
toggleCalendarDays()
} catch (error) {
console.error('Error initializing calendar sizes:', error)
}
}
?? 動(dòng)畫實(shí)現(xiàn)
CSS 過(guò)渡效果
.calendar-wrapper {
position: relative;
transition: all 0.3s ease;
overflow-y: auto;
}
.toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
/* 展開狀態(tài)下的旋轉(zhuǎn)效果 */
&.rotate {
transform: rotate(180deg);
}
}
/* 全局樣式覆蓋 */
:global(.nut-calendarcard-days) {
transition: max-height 0.3s ease;
}
加載狀態(tài)動(dòng)畫
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f3f3f3;
border-top: 2px solid #fa6c21;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
?? 使用示例
<template>
<Calender
:default-expanded="false"
@change="handleDateChange"
@toggle="handleToggle"
/>
</template>
<script setup>
const handleDateChange = (date) => {
console.log('選中日期:', date)
}
const handleToggle = (isExpanded) => {
console.log('展開狀態(tài):', isExpanded)
}
</script>
?? 擴(kuò)展思路
- 多選模式: 支持日期范圍選擇
- 事件標(biāo)記: 在日期上顯示事件標(biāo)記
- 農(nóng)歷支持: 顯示農(nóng)歷日期
- 主題切換: 支持多種視覺主題
- 國(guó)際化: 支持多語(yǔ)言和不同日期格式
?? 總結(jié)
這個(gè)展開/收縮功能的實(shí)現(xiàn)核心在于:
-
狀態(tài)管理: 通過(guò)
isExpanded控制顯示模式 - DOM 操作: 直接操作容器高度和元素顯示狀態(tài)
- 日期計(jì)算: 精確計(jì)算當(dāng)前周和當(dāng)前月的日期范圍
- 動(dòng)畫效果: 使用 CSS transition 實(shí)現(xiàn)平滑過(guò)渡
- 性能優(yōu)化: 防抖處理和緩存機(jī)制
通過(guò)這種實(shí)現(xiàn)方式,既保證了功能的完整性,又確保了良好的用戶體驗(yàn)和性能表現(xiàn)。這種設(shè)計(jì)模式可以應(yīng)用到其他需要展開/收縮功能的組件中,具有很好的復(fù)用價(jià)值。
全部代碼
<template>
<!-- 日歷容器:使用 SkyCard 組件提供統(tǒng)一的卡片樣式 -->
<SkyCard class="calendar-container">
<!-- 日歷主體區(qū)域:包含展開/收縮狀態(tài)控制 -->
<view class="calendar-wrapper" :class="{ expanded: isExpanded }">
<!-- 加載狀態(tài)指示器 -->
<view v-if="isLoading" class="loading-overlay">
<view class="loading-spinner"></view>
</view>
<!-- NutUI 日歷卡片組件:提供基礎(chǔ)的日歷功能 -->
<nut-calendar-card
ref="calendarRef"
v-model="value"
:disable-day="disableDay"
@change="onChange"
></nut-calendar-card>
</view>
<!-- 展開/收縮切換按鈕:用戶交互入口 -->
<view
class="toggle-button"
:class="{ 'toggle-button--loading': isLoading }"
role="button"
:aria-label="isExpanded ? '收起日歷' : '展開日歷'"
:aria-expanded="isExpanded"
@click="handleToggleMode"
>
<!-- 動(dòng)態(tài)圖標(biāo):根據(jù)展開狀態(tài)顯示不同方向的箭頭 -->
<image
:src="images[IconImageName.Down]"
class="toggle-icon"
:class="{ rotate: isExpanded }"
:alt="isExpanded ? '收起' : '展開'"
/>
</view>
</SkyCard>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, nextTick, watch, onUnmounted } from 'vue'
import { CalendarCardDay } from '@nutui/nutui-taro'
import { useReady } from '@tarojs/taro'
import images from '~/assets/icon-image/images'
import IconImageName from '~/assets/icon-image/const'
import { SkyCard } from '~/components'
// ==================== 類型定義 ====================
/** 日期范圍接口 */
interface DateRange {
firstDay: Date
lastDay: Date
}
/** 組件 Props 接口 */
interface Props {
/** 默認(rèn)展開狀態(tài) */
defaultExpanded?: boolean
/** 是否顯示加載狀態(tài) */
showLoading?: boolean
/** 自定義禁用規(guī)則 */
customDisableDay?: (day: CalendarCardDay) => boolean
}
/** 組件 Emits 接口 */
interface Emits {
/** 日期變化事件 */
(e: 'change', date: Date): void
/** 展開狀態(tài)變化事件 */
(e: 'toggle', isExpanded: boolean): void
}
// ==================== Props & Emits ====================
const props = withDefaults(defineProps<Props>(), {
defaultExpanded: false,
showLoading: false,
})
const emit = defineEmits<Emits>()
// ==================== 響應(yīng)式數(shù)據(jù)定義 ====================
/** 當(dāng)前選中的日期值 */
const value = ref<Date | null>(null)
/** 控制日歷展開/收縮狀態(tài) */
const isExpanded = ref(props.defaultExpanded)
/** 所有日期元素的總高度(展開模式) */
const allDaysHeight = ref(0)
/** 單行日期元素的高度(收縮模式) */
const oneDayHeight = ref(60)
/** 加載狀態(tài) */
const isLoading = ref(false)
/** 日歷組件引用 */
const calendarRef = ref()
/** 防抖定時(shí)器 */
let debounceTimer: NodeJS.Timeout | null = null
// ==================== 計(jì)算屬性 ====================
/** 當(dāng)前日期范圍(緩存優(yōu)化) */
const currentDateRange = computed<DateRange>(() => {
return isExpanded.value ? getMonthRange() : getWeekRange()
})
/** 切換按鈕的 ARIA 標(biāo)簽 */
const toggleButtonAriaLabel = computed(() => {
return isExpanded.value ? '收起日歷視圖' : '展開日歷視圖'
})
// ==================== 工具函數(shù) ====================
/**
* 防抖函數(shù) - 優(yōu)化頻繁操作
* @param fn 要執(zhí)行的函數(shù)
* @param delay 延遲時(shí)間
*/
const debounce = <T extends (...args: any[]) => any>(
fn: T,
delay: number
): T => {
return ((...args: any[]) => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => fn(...args), delay)
}) as T
}
/**
* 安全獲取 DOM 元素
* @param selector 選擇器
* @param index 索引
* @returns DOM 元素或 null
*/
const safeQuerySelector = (
selector: string,
index: number = 0
): HTMLElement | null => {
try {
const elements = document.querySelectorAll(selector)
return (elements[index] as HTMLElement) || null
} catch (error) {
console.warn(`Failed to query selector: ${selector}`, error)
return null
}
}
/**
* 獲取本周的日期范圍(周日到周六)
* 用于收縮模式下的日期范圍控制
*
* @returns 包含本周開始和結(jié)束日期的對(duì)象
*/
function getWeekRange(): DateRange {
const today = new Date()
// 計(jì)算本周日的日期(一周的開始)
// getDay() 返回 0-6,0 表示周日,需要特殊處理
const dayOfWeek = today.getDay() === 0 ? 7 : today.getDay()
const firstDay = new Date(today)
firstDay.setDate(today.getDate() - dayOfWeek) // 回退到本周日
firstDay.setHours(0, 0, 0, 0) // 設(shè)置為當(dāng)天開始時(shí)間
// 計(jì)算本周六的日期(一周的結(jié)束)
const lastDay = new Date(firstDay)
lastDay.setDate(firstDay.getDate() + 6) // 前進(jìn)6天到周六
lastDay.setHours(23, 59, 59, 999) // 設(shè)置為當(dāng)天結(jié)束時(shí)間
return { firstDay, lastDay }
}
/**
* 獲取本月的日期范圍
* 用于展開模式下的日期范圍控制
*
* @returns 包含本月開始和結(jié)束日期的對(duì)象
*/
function getMonthRange(): DateRange {
const today = new Date()
const month = today.getMonth()
const year = today.getFullYear()
// 本月第一天
const firstDay = new Date(year, month, 1)
// 本月最后一天(通過(guò)設(shè)置下月第0天實(shí)現(xiàn))
const lastDay = new Date(year, month + 1, 0)
return { firstDay, lastDay }
}
// ==================== 事件處理函數(shù) ====================
/**
* 日期選擇變化回調(diào)
* @param val 選中的日期對(duì)象
*/
const onChange = (val: Date) => {
console.log('日期選擇變化:', val)
emit('change', val)
}
/**
* 切換模式處理函數(shù)(帶防抖)
*/
const handleToggleMode = debounce(() => {
if (isLoading.value) return
isLoading.value = true
try {
toggleMode()
} finally {
// 延遲重置加載狀態(tài),確保動(dòng)畫完成
setTimeout(() => {
isLoading.value = false
}, 300)
}
}, 100)
// ==================== 核心業(yè)務(wù)邏輯 ====================
/**
* 智能日期禁用規(guī)則
* 根據(jù)當(dāng)前展開狀態(tài)決定哪些日期可以被選擇
*
* @param day 日歷日期對(duì)象
* @returns true表示禁用,false表示可選
*/
const disableDay = (day: CalendarCardDay): boolean => {
// 如果有自定義禁用規(guī)則,優(yōu)先使用
if (props.customDisableDay) {
return props.customDisableDay(day)
}
const { firstDay, lastDay } = currentDateRange.value
const current = new Date(day.year, day.month - 1, day.date)
return current < firstDay || current > lastDay
}
// ==================== 用戶交互處理 ====================
/**
* 切換展開/收縮模式
* 核心功能:在兩種顯示模式間切換
*/
const toggleMode = () => {
// 切換展開狀態(tài)
isExpanded.value = !isExpanded.value
// 觸發(fā)事件
emit('toggle', isExpanded.value)
// 控制日歷日期元素的展開/收縮
toggleCalendarDays()
}
/**
* 控制日歷日期元素的展開/收縮
* 通過(guò)直接操作 DOM 實(shí)現(xiàn)平滑的視覺效果
*/
const toggleCalendarDays = () => {
nextTick(() => {
// 使用緩存的 DOM 查詢結(jié)果
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found')
return
}
try {
if (isExpanded.value) {
// 展開模式:顯示所有日期
calendarDays.style.height = `${allDaysHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 恢復(fù)所有日期的顯示
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
dayElement.style.display = ''
})
} else {
// 收縮模式:只顯示當(dāng)前周
calendarDays.style.height = `${oneDayHeight.value}px`
calendarDays.style.transition = 'all 0.3s ease'
// 隱藏非當(dāng)前周的日期(通過(guò)禁用狀態(tài)判斷)
const allDays = calendarDays.querySelectorAll('.nut-calendarcard-day')
allDays.forEach((day) => {
const dayElement = day as HTMLElement
if (dayElement.classList.contains('disabled')) {
dayElement.style.display = 'none'
} else {
dayElement.style.display = ''
}
})
}
} catch (error) {
console.error('Error toggling calendar days:', error)
}
})
}
/**
* 初始化日歷尺寸信息
*/
const initializeCalendarSizes = async () => {
try {
await nextTick()
// 獲取日期容器元素
const calendarDays = safeQuerySelector('.nut-calendarcard-days', 1)
if (!calendarDays) {
console.warn('Calendar days container not found during initialization')
return
}
// 獲取展開模式下的總高度
const height = calendarDays.getBoundingClientRect().height
allDaysHeight.value = height
// 獲取單個(gè)日期元素的高度
const oneDay = safeQuerySelector('.nut-calendarcard-day', 0)
if (oneDay) {
oneDayHeight.value = oneDay.getBoundingClientRect().height
}
// 初始化日歷的展開/收縮狀態(tài)
toggleCalendarDays()
} catch (error) {
console.error('Error initializing calendar sizes:', error)
}
}
// ==================== 生命周期管理 ====================
/**
* 組件掛載時(shí)的初始化
*/
onMounted(() => {
// 設(shè)置當(dāng)前日期為默認(rèn)選中值
value.value = new Date()
})
/**
* 頁(yè)面準(zhǔn)備就緒時(shí)的初始化
* 獲取必要的 DOM 尺寸信息并初始化展開/收縮狀態(tài)
*/
useReady(() => {
// 延遲初始化,確保 DOM 完全渲染
setTimeout(() => {
initializeCalendarSizes()
}, 100)
})
/**
* 組件卸載時(shí)的清理
*/
onUnmounted(() => {
// 清理防抖定時(shí)器
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
// ==================== 監(jiān)聽器 ====================
/**
* 監(jiān)聽展開狀態(tài)變化,更新 ARIA 屬性
*/
watch(isExpanded, (newValue) => {
// 可以在這里添加額外的狀態(tài)變化處理邏輯
console.log('Calendar expanded state changed:', newValue)
})
/**
* 監(jiān)聽加載狀態(tài)變化
*/
watch(isLoading, (newValue) => {
if (newValue) {
console.log('Calendar is loading...')
}
})
</script>
<style lang="less" scoped>
/* ==================== 容器樣式 ==================== */
.calendar-container {
position: relative;
}
/* ==================== 日歷包裝器樣式 ==================== */
.calendar-wrapper {
position: relative;
transition: all 0.3s ease;
overflow-y: auto; /* 添加垂直滾動(dòng)支持 */
/* 展開狀態(tài)樣式 */
&.expanded {
/* 可以添加展開狀態(tài)的特殊樣式 */
}
}
/* ==================== 加載狀態(tài)樣式 ==================== */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f3f3f3;
border-top: 2px solid #fa6c21;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* ==================== 切換按鈕樣式 ==================== */
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
cursor: pointer;
transition: all 0.3s ease;
user-select: none; /* 防止文本選擇 */
/* 加載狀態(tài)樣式 */
&--loading {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* 點(diǎn)擊反饋效果 */
&:active:not(&--loading) {
opacity: 0.8;
transform: scale(0.95);
}
/* 焦點(diǎn)狀態(tài)(無(wú)障礙訪問(wèn)) */
&:focus-visible {
outline: 2px solid #3498db;
outline-offset: 2px;
}
/* 箭頭圖標(biāo)樣式 */
.toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
pointer-events: none; /* 防止圖標(biāo)干擾點(diǎn)擊事件 */
/* 展開狀態(tài)下的旋轉(zhuǎn)效果 */
&.rotate {
transform: rotate(180deg);
}
}
}
/* ==================== 全局樣式覆蓋 ==================== */
/* 控制日歷日期元素的展開/收縮過(guò)渡效果 */
:global(.nut-calendarcard-days) {
transition: max-height 0.3s ease;
}
/* ==================== 響應(yīng)式設(shè)計(jì) ==================== */
@media (max-width: 768px) {
.toggle-button {
padding: 10px 12px;
.toggle-icon {
width: 14px;
height: 14px;
}
}
.loading-spinner {
width: 20px;
height: 20px;
}
}
/* ==================== 高對(duì)比度模式支持 ==================== */
@media (prefers-contrast: high) {
.toggle-button {
border: 1px solid currentColor;
&:focus-visible {
outline: 3px solid currentColor;
}
}
.loading-overlay {
background: rgba(0, 0, 0, 0.8);
}
}
/* ==================== 減少動(dòng)畫模式支持 ==================== */
@media (prefers-reduced-motion: reduce) {
.calendar-wrapper,
.toggle-button,
.toggle-icon,
:global(.nut-calendarcard-days) {
transition: none !important;
}
.loading-spinner {
animation: none;
}
}
</style>
本文檔持續(xù)更新中,如有問(wèn)題或建議,歡迎反饋。