likes
comments
collection
share

用日历数据写一个 vue 自定义日历组件

作者站长头像
站长
· 阅读数 2

说明

本文语法使用的是组合式,需要vue3或者 vue2.7

组件样式使用的是 tailwindcss,这里只是简单介绍下我的日历组件是如何构成的。

本来打算开源的,后来想想也不好看,也只有一个组件,就算了。

思路

日历头

<div class="calendar" :style="{ 'background-color': backgroundColor }">
  <div v-if="showHeader" class="header flex justify-between items-center px-3 py-2 border-b">
    <div class="title">
      <span v-if="showHeaderYear">{{ selectDate.year() }} 年 </span>
      {{ selectDate.month() + 1 }} 月
    </div>
    <div class="button-group flex items-center justify-end">
      <button
        class="button border rounded-l py-1 px-4 text-center text-xs lg:hover:text-sky-600 lg:hover:bg-sky-100 lg:hover:border-sky-200"
        style="margin-right: -1px" @click="changeMonth(-1)">上个月</button>
      <button
        class="button border py-1 px-4 text-center text-xs lg:hover:text-sky-600 lg:hover:bg-sky-100 lg:hover:border-sky-200"
        @click="today">今天</button>
      <button
        class="button border rounded-r py-1 px-4 text-center text-xs lg:hover:text-sky-600 lg:hover:bg-sky-100 lg:hover:border-sky-200"
        style="margin-left: -1px" @click="changeMonth(1)">下个月</button>
    </div>
  </div>

日历头很简单,只有左边一个标题显示了当前的年和月,右边是三个控制跳转的按钮,分别是上个月今天下个月

其中有几个是传入的可选 props

// 日历背景颜色
backgroundColor: {
  type: String,
  default: '#fff',
},
// 是否显示日历头
showHeader: {
  type: Boolean,
  default: true
},

这里面的selectDate 是当前选择的日期经过 dayjs 格式化后的对象

// 传入的日期,使用时通常传入今天
date: {
  type: String,
  default: dayjs().format('YYYY-MM-DD')
},
// 传入日期的 dayjs 格式化对象
const selectDate = computed(() => dayjs(props.date))

此处的 changeMonth和 today 方法稍后再说

日历 body 部分

背景显示月份

其实这个有没有都行,也很少有需求会点名要这个功能

<div class="body relative p-2">
  <div v-if="showMonthOnBackground"
    :class="['month-shadow', 'absolute', 'text-9xl', 'top-1/2', 'left-1/2', 'text-gray-100/80', '-translate-x-2/4', '-translate-y-2/4', 'pointer-events-none']">
    {{ month.split('-')[1] }}
  </div>

它的作用就是在日历的背景中显示一个月份的虚影

// 是否背景显示月份虚影
showMonthOnBackground: {
  type: Boolean,
  default: false
},
// 当前选中日期的年-月
const month = computed(() => {
  return `${selectDate.value.year()}-${selectDate.value.month() + 1}`
})

日历周标题

考虑到可能会有定制周标题的需求,于是开放成了 props

// 星期的显示文字
weekDays: {
  type: Array,
  default: () => ['一', '二', '三', '四', '五', '六', '日']
},
// 一周是否开始于周一
weekStartsOnMonday: {
  type: Boolean,
  default: true
},
// 星期的样式
weekDayStyle: {
  type: Object,
  default: () => ({})
},

const _weekDays = computed(() => {
  let weekDays = [...props.weekDays];
  if (!props.weekStartsOnMonday) {
    const lastDay = weekDays.pop()
    weekDays.unshift(lastDay)
  }
  return weekDays;
})

配置 weekDays 时不用管一周开始于周几,按周一至周日排,如果将 weekStartsOnMonday 配置成 false 时会自动将周日放到第一位的

<div class="weekdays grid grid-cols-7">
  <div v-for="weekday in _weekDays" :key="weekday" class="weekday text-center text-base py-3"
    :style="weekDayStyle">{{ weekday }}
  </div>
</div>

日历日期部分

<div class="week relative grid grid-cols-7" v-for="(week, weekIndex) in calendar">
  <div v-for="(day, dayIndex) in week" :key="day.fullDate" :class="[
'day',
'text-center',
'text-base',
'cursor-pointer',
'lg:hover:bg-blue-200',
'transition-colors',
{
'border-r': bordered,
'border-b': bordered,
'border-t': !weekIndex && bordered,
'border-l': !dayIndex && bordered,
'pointer-events-none': isCellDisabled(day),
'opacity-50': isCellDisabled(day),
'invisible': isHiddenCell(day),
}]" @click="onSelect(day)" :style="dateCellStyle">
    <div
      :class="['day-inner', 'py-2', { 'text-gray-400': !day.isCurrentMonth, 'text-sky-500': day.isToday || selectDate.isSame(day.fullDate, 'day'), 'bg-blue-200': selectDate.isSame(day.fullDate, 'day') }]">
      <slot :day="day" name="date-cell"><span>{{ day.date }}</span></slot>
    </div>
  </div>
</div>

这里要说明的比较多,首先日历数据 calendar

const calendar = ref([])
// 每当选中日期的月份变化了之后,就重新计算日历数据
watch(month, () => {
  calendar.value = generateCalendar(selectDate.value, {
    weekStartsOnMonday: props.weekStartsOnMonday
  })
}, { immediate: true })

得到的是一个二维数组,所以在画UI 时要循环两次,一次是周,一次是日期。

样式部分没什么好说的,只有一点要解释一下,为什么在加 hover 时要加上个前缀 lg,是因为这个组件我是移动端和桌面端同时使用的,在移动端时会有 hover 残留的现象,所以为了解决就加上了lg。

isCellDisabled 是一个判断该日期是否禁止的方法

// 是否禁止选择非当前月份的日期
disableNonCurrentMonth: {
  type: Boolean,
  default: false
},
// 禁止选中的日期
disabledDate: {
  type: Function,
  default: () => false
},

function isCellDisabled(day) {
  if (props.disableNonCurrentMonth && !day.isCurrentMonth) {
    return true
  }
  return props.disabledDate(day)
}

除了可以配置禁止选择当前月份的日期外,还可以自行配置禁止的日期,例如:

<Calendar :disabledDate="handleDisabledDate" />

function handleDisabledDate (day) {
 if ([2, 3, 4, 5, 6].includes(day.date)){
   return true
 }
  return false
}

isHiddenCell 是一个判断日期是否隐藏的方法,用于判断这一天是否需要隐藏不显示

// 是否显示非本月的日期
showOtherMonth: {
  type: Boolean,
  default: true
},
// 是否显示上个月的日期
showPrevMonth: {
  type: Boolean,
  default: true
},
// 是否显示下个月的日期
showNextMonth: {
  type: Boolean,
  default: true
},

function isHiddenCell(day) {
  if (day.isCurrentMonth) return false;
  if (!props.showOtherMonth) return true;
  if (!props.showPrevMonth && day.isPrevMonth) return true;
  if (!props.showNextMonth && day.isNextMonth) return true;
}

显示日期

因为有自定义日期内容的需求,所以日期部分写成了个插槽,将日期的数据传递出去,如何显示自己决定,默认只是一个日期

<Calendar>
  <template #date-cell="{ day }">
    <span>自定义内容{{ day.fullDate }}</span>
  </template>
</Calendar>

切换日期

下面说下在点击切换日期时 还有之前提到的上个月 今天 下个月三个按钮都做了什么

其实他们做的都是同一个事,就是触发一个 onSelect 的 emit,由父组件来改变当前选中的日期

// 点击上/下个月选择的日期,first: 上/下个月第一天,last: 上/下个月最后一天,none: 当前选择日期的上/下个月
monthSelectType: {
  type: String,
  default: 'none',
},

const emit = defineEmits(['onSelect'])
const FORMAT = 'YYYY-MM-DD';

function today() {
  const today = dayjs();
  emit('onSelect', today.format(FORMAT), {
    from: 'today',
    isOverMonth: !selectDate.value.isSame(today, 'month')
  })
}

function onSelect(newDate) {
  emit('onSelect', newDate.fullDate, {
    from: 'select',
    isOverMonth: !selectDate.value.isSame(newDate.fullDate, 'month')
  })
}

function changeMonth(delta) {
  const newMonthDay = selectDate.value.add(delta, 'month');

  let targetDate = null;

  if (props.monthSelectType === 'first') {
    targetDate = newMonthDay.startOf('month');
  } else if (props.monthSelectType === 'last') {
    targetDate = newMonthDay.endOf('month');
  } else {
    targetDate = newMonthDay;
  }

  emit('onSelect', targetDate.format(FORMAT), {
    from: delta > 0 ? 'next' : 'prev',
    isOverMonth: true,
  })
}

除了向父组件传递了新的日期之外,还传了一个数据,实际上目前的使用过程中只有 isOverMonth 这个参数用到了

向外暴露选择上下月份的方法

有时候并不想使用自带的 header,而是在页面上其他位置自定义了上下月份的按钮, 所以组件要将这两个方法暴露出去

function prevMonth() {
  changeMonth(-1)
}
function nextMonth() {
  changeMonth(1)
}
defineExpose({
  prevMonth,
  nextMonth,
})

使用时:

<Calendar ref="calendarRef" />

const calendarRef = ref(null)

function handleNextMonth() {
  calendarRef.value.nextMonth()
}

使用

使用这个日历组件时要传入选择的日期和改变日期的方法

<Calendar :date="date" @onSelect="handleSelectDate" />

const date = ref(dayjs().format('YYYY-MM-DD'))

function handleSelectDate(newDate, { isOverMonth }) {
  date.value = newDate;
  // 可以在这获取这日的数据
  if (isOverMonth) {
    // 可以在这当跨月时获取月份所需的数据
  }
}