在工作中,经常需要使用日期选择器来让用户方便地选择日期。实现一个日期选择器组件。
先来看看今天要实现的效果
基础布局
popper 和日期选择器对应的元素
- 触发器:也就是输入框
- 弹出层:日期面板
看看下面的基础布局,为了简化对时间的操作,这里使用 dayjs
date-picker.ts
// date-picker.ts
import { ExtractPropTypes, InjectionKey, PropType, Ref } from "vue";
import type { Dayjs } from "dayjs";
export const DatePickerProps = {
modelValue: [String, Number, Date],
format: String,
valueFormat: String,
disabledDate: {
type: Function as PropType<(time: Date) => boolean>,
},
placeholder: String,
type: {
type: String as PropType<"date" | "year" | "month">,
default: "date",
},
shortcuts: {
type: Array as PropType<Array<{ text: string; value: Date | Function }>>,
default: () => [],
},
prefixIcon: {
type: String,
default: "calendar",
},
};
export type DatePickerTypes = ExtractPropTypes<typeof DatePickerProps>;
export const DatePickerContextKey: InjectionKey<{
date: Ref<Dayjs>;
disabledDate?: (time: Date) => boolean;
}> = Symbol("DatePickerContextKey");
export type DateCellType =
| "normal"
| "today"
| "week"
| "next-month"
| "prev-month";
export interface DateCell {
text?: number;
disabled?: boolean;
isSelected?: boolean;
isCurrent?: boolean;
date: Dayjs;
type?: DateCellType;
}
date-picker.vue
<template>
<VanTooltip ref="tooltipRef" v-bind="tooltipProps">
<div class="van-date-editor">
<VanInput
v-model="value"
:prefix-icon="!$slots['prefix-icon'] ? prefixIcon : ''"
:placeholder="placeholder"
clearable
>
<template #prefix>
<slot name="prefix-icon" />
</template>
</VanInput>
</div>
<template #content>
<VanPickerPanel
:class="n()"
:type="type"
:date="date"
@pick="handlePick"
:shortcuts="shortcuts"
/>
</template>
</VanTooltip>
</template>
<script lang="ts" setup>
import { reactive, computed, provide, ref, nextTick } from "vue";
import { VanTooltip, VanInput } from "@vangle/components";
import VanPickerPanel from "./components/picker-panel.vue";
import { createNamespace } from "@vangle/utils";
import {
DateCell,
DatePickerProps,
DatePickerTypes,
DatePickerContextKey,
} from "./date-picker";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat.js";
import advancedFormat from "dayjs/plugin/advancedFormat.js";
import localeData from "dayjs/plugin/localeData.js";
import weekOfYear from "dayjs/plugin/weekOfYear.js";
import weekYear from "dayjs/plugin/weekYear.js";
import dayOfYear from "dayjs/plugin/dayOfYear.js";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js";
dayjs.extend(localeData);
dayjs.extend(advancedFormat);
dayjs.extend(customParseFormat);
dayjs.extend(weekOfYear);
dayjs.extend(weekYear);
dayjs.extend(dayOfYear);
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
defineOptions({
name: "VanDatePicker",
});
const props = defineProps(DatePickerProps);
const emit = defineEmits<{
(e: "update:modelValue", val: DatePickerTypes["modelValue"]): void;
(e: "change", val: Date | string | number): void;
}>();
const { n } = createNamespace("date-picker");
const tooltipRef = ref();
const tooltipProps = reactive<any>({
effect: "light",
pure: true,
trigger: "click",
transitionName: "van-select-menu",
popperClass: "van-picker__popper",
});
const formats = {
year: "YYYY",
month: "YYYY-MM",
date: "YYYY-MM-DD",
};
const format = computed(() => {
return props.format ? props.format : formats[props.type];
});
const date = computed<Dayjs>({
get: () => (props.modelValue ? dayjs(props.modelValue) : dayjs()),
set: (val) => {
const d = val.toDate();
// 格式化日期
const value = props.valueFormat ? val.format(props.valueFormat) : d;
// 触发事件
emit("update:modelValue", value);
emit("change", value);
// 关闭弹出层
nextTick(() => {
tooltipRef.value.close();
});
},
});
const value = computed({
get: () => {
const d = dayjs(props.modelValue);
return d.isValid() ? d.format(format.value) : "";
},
set: (val) => {
// 将输入的值转换为dayjs日期
const d = dayjs(val);
// 如果不是合格的日期不做操作
if (d.isValid()) return;
// 格式化日期
const value = props.valueFormat ? d.format(props.valueFormat) : d.toDate();
emit("update:modelValue", value);
emit("change", value);
},
});
// 选择事件监听
function handlePick(cell: DateCell) {
date.value = cell.date;
}
provide(DatePickerContextKey, {
date,
disabledDate: props.disabledDate,
});
</script>
<style lang="less">
@import "./date-picker.less";
</style>
- 在这个日期选择器组件中,使用了
VanTooltip
组件来作为触发器和弹出层的容器。这实际上是对 Popper 组件的进一步封装。直接用 Popper 替换也可。 - 由于在代码中使用了 Dayjs 的一些额外功能,比如扩展插件,所以在开始使用 Dayjs 的功能之前,通过
dayjs.extend()
方法注册了这些扩展。这确保了可以在组件中使用这些功能。 - 定义了两个计算属性 date 和 value,date 用于日期选择面板,value 用于输入框
- 在
VanTooltip
组件的content
插槽中,嵌入了VanPickerPanel
组件,即日期选择面板。这个面板负责显示日期的选择界面,包括日期单元格、快捷方式等。
PickerPanel 选择面板
之所以再抽离一个面板出来,主要是有一些公共的部分。例如日期前后切换和快捷方式等
<template>
<div :class="[n()]" @mouseup.stop>
<div :class="[n('wrapper')]">
<div v-if="shortcuts && shortcuts.length" :class="[n('sidebar')]">
<button
v-for="item in shortcuts"
:key="item.text"
:class="n('shortcut')"
@click="handleClick(item)"
>
{{ item.text }}
</button>
</div>
<div :class="[n('body')]">
<div :class="[n('header')]">
<span :class="n('prev-btn')">
<button :class="n('icon-btn')" @click="change('year', -1)">
<VanIcon name="d-arrow-left" />
</button>
<button
v-if="type === 'date'"
:class="n('icon-btn')"
@click="change('month', -1)"
>
<VanIcon name="arrow-left" />
</button>
</span>
<div :class="[n('header-label')]">
<button>{{ year }}</button>
<button v-if="type === 'date'">{{ month }}</button>
</div>
<span :class="n('next-btn')">
<button
v-if="type === 'date'"
:class="n('icon-btn')"
@click="change('month', 1)"
>
<VanIcon name="arrow-right" />
</button>
<button :class="n('icon-btn')" @click="change('year', 1)">
<VanIcon name="d-arrow-right" />
</button>
</span>
</div>
<div :class="[n('content')]">
<DateTable
v-if="type === 'date'"
:date="insertDate"
@pick="handlePick"
/>
<YearTable
v-else-if="type === 'year'"
:date="insertDate"
@pick="handlePick"
/>
<MonthTable
v-else-if="type === 'month'"
:date="insertDate"
@pick="handlePick"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { VanIcon } from "@vangle/components";
import { createNamespace } from "@vangle/utils";
import { DateCell } from "../date-picker";
import DateTable from "./date-table.vue";
import YearTable from "./year-table.vue";
import MonthTable from "./month-table.vue";
import dayjs, { Dayjs, ManipulateType } from "dayjs";
defineOptions({
name: "VanPickerPanel",
});
type Shortcut = { text: string; value: Date | Function };
const props = withDefaults(
defineProps<{
date: Dayjs;
type: string;
shortcuts: Array<Shortcut>;
}>(),
{
date: () => dayjs(),
}
);
const emit = defineEmits(["pick", "change"]);
const { n } = createNamespace("picker-panel");
const insertDate = ref(dayjs());
const year = computed(() => insertDate.value.get("year"));
const month = computed(() => insertDate.value.format("MMMM"));
function handlePick(cell: DateCell) {
emit("pick", cell);
}
// 点击shortcut
function handleClick(item: Shortcut) {
const d = typeof item.value === "function" ? item.value() : item.value;
const cell: DateCell = { date: dayjs(d) };
emit("pick", cell);
}
// 统一处理日期切换
function change(type: ManipulateType, num: number) {
if (props.type === "year") {
insertDate.value = dayjs(insertDate.value.toDate()).add(num * 10, type);
} else if (props.type === "month") {
insertDate.value = dayjs(insertDate.value.toDate()).add(num, "year");
} else {
insertDate.value = dayjs(insertDate.value.toDate()).add(num, type);
}
}
watch(
() => props.date,
() => {
insertDate.value = dayjs(props.date.toDate());
},
{ immediate: true }
);
</script>
- 布局分为左右两部份,左边是
shortcuts
,右侧是日期选择器的主体。 - 在主体中分为 header 和 content,header 中定义日期切换元素,根据 type 的不同有不同的显示效果。
shortcuts
传入的格式如下,value 可以是日期对象或者一个函数返回一个日期对象。因此在handleClick
点击chortcuts
时,对其进行了处理。
const shortcuts = [
{
text: "Today",
value: new Date(),
},
{
text: "Yesterday",
value: () => {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24);
return date;
},
},
{
text: "A week ago",
value: () => {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24 * 7);
return date;
},
},
];
- 定义 change 函数统一处理日期切换功能,得益于 dayjs 的完美封装,让这个函数格外简单
上面写了这么多都是在搭建整体的架子,可能部分读者还不清楚到底是在干啥,接下来就进入日期面板的主体封装,就是上面根据类型显示的几个组件 DateTable
、MonthTable
、YearTable
DateTable、MonthTable、YearTable 封装
DateTable
date-table.vue
<template>
<table :class="n()">
<tbody>
<tr>
<th v-for="item in WEEKS" :aria-label="item + ''" scope="col">
{{ item }}
</th>
</tr>
<tr v-for="(row, rowKey) in rows" :key="rowKey">
<td
v-for="(col, colKey) in row"
:key="`${rowKey + colKey}`"
:class="[col.type, { disabled: col.disabled }]"
>
<div
:class="[n('cell'), { selected: col.isSelected }]"
@click="handlePick(col)"
>
<span :class="n('cell-text')">{{ col.text }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script lang="ts" setup>
import { computed, ref, inject } from "vue";
import { createNamespace, isFunction } from "@vangle/utils";
import { DateCell, DatePickerContextKey, DateCellType } from "../date-picker";
import dayjs, { Dayjs } from "dayjs";
defineOptions({
name: "VanPickerPanel",
});
const props = withDefaults(
defineProps<{
date: Dayjs;
}>(),
{
date: () => dayjs(),
}
);
const emit = defineEmits(["pick"]);
// panel 中注入的数据
const datePicker = inject(DatePickerContextKey);
// 面板行数
const tableRows = ref<DateCell[][]>([[], [], [], [], [], []]);
const WEEKS_CONSTANT = computed(() =>
props.date
.locale("en")
.localeData()
.weekdaysShort()
.map((_) => _.toLowerCase())
);
// 表头数据
const WEEKS = computed(() =>
WEEKS_CONSTANT.value.map((w) => w[0].toUpperCase() + w.substring(1))
);
// 表格开始日期
const startDate = computed(() => {
const startDayOfMonth = props.date.startOf("month");
return startDayOfMonth.subtract(startDayOfMonth.day() || 7, "day");
});
// 表格数据
const rows = computed(() => {
const rows_ = tableRows.value;
const cols = WEEKS.value.length;
// 当前选中的日期
const cur = props.date;
// 当月第一天
const monthDstartDay = cur.startOf("month").day();
// 当月最后一天
const lastDate = cur.endOf("month").date();
let count = 1;
// 循环填充表格,6行7列
for (let row = 0; row < tableRows.value.length; row++) {
for (let col = 0; col < cols; col++) {
const cellDate = startDate.value.add(count, "day");
const text = cellDate.date();
// 是否选中
const disabled =
isFunction(datePicker?.disabledDate) &&
datePicker!.disabledDate(cellDate.toDate());
// 默认当月日期
const isSelected =
cellDate.format("YYYY-MM-DD") ===
datePicker?.date.value.format("YYYY-MM-DD");
let type: DateCellType = "normal";
if (count < monthDstartDay) {
// 上个月日期
type = "prev-month";
} else if (count - monthDstartDay >= lastDate) {
// 下个月日期
type = "next-month";
}
rows_[row][col] = {
type,
date: cellDate,
text,
isSelected,
disabled,
};
count++;
}
}
return rows_;
});
const { n } = createNamespace("date-table");
function handlePick(cell: DateCell) {
if (cell.disabled) return;
emit("pick", cell);
}
</script>
- 首先使用 dayjs 获取到 WEEKS,也就是表头数据(星期的名称)
- 日期表格为 6 行 7 列,42 个天数
- 使用计算属性得到开始的日期
startDate
,例如:这个月第一天是礼拜 3,那么表格开始日期就要往前退 3 天,使用 dayjs 的 subtract 减去就得到开始日期 - rows 为表格的真实数据,每次 date 改变都会重新计算得到根据当前 date 的表格数据
handlePick
点击日期后触发 pick 事件并把 cell 对象传递过去。在上面 panel 中有对 pick 事件的监听
cell 类型如下
export interface DateCell {
text?: number;
disabled?: boolean;
isSelected?: boolean;
isCurrent?: boolean;
date: Dayjs;
type?: DateCellType;
}
MonthTable 组件
month-table.vue
<template>
<table :class="n()">
<tbody>
<tr v-for="row in 3" :key="row">
<td
v-for="col in 4"
:key="`${row + '_' + col}`"
@click="handlePick(row, col)"
>
<div
:class="[
n('cell'), { selected: isSelect(row, col) }
]"
>
<span :class="n('cell-text')"
>{{ MONTHS[getIndex(row, col)] }}</span
>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script lang="ts" setup>
import { computed, inject } from "vue";
import { createNamespace } from "@vangle/utils";
import { DatePickerContextKey } from "../date-picker";
import dayjs, { Dayjs } from "dayjs";
defineOptions({
name: "VanPickerPanel",
});
const props = withDefaults(
defineProps<{
date: Dayjs;
}>(),
{
date: () => dayjs(),
}
);
const emit = defineEmits(["pick"]);
const datePicker = inject(DatePickerContextKey);
const { n } = createNamespace("month-table");
// 得到所有月份的简写
const MONTHS = computed(() =>
props.date.locale("en").localeData().monthsShort()
);
// 选择对应的月份
function handlePick(row: number, col: number) {
const month = getIndex(row, col);
if (month) {
// 拼装cell对象
const cell = {
date: dayjs(props.date.set("month", month)),
};
// 触发picker事件
emit("pick", cell);
}
}
function isSelect(row: number, col: number) {
return (
datePicker?.date.value.year() === props.date.year() &&
datePicker?.date.value.format("MMM") === MONTHS.value[getIndex(row, col)]
);
}
// 计算索引
function getIndex(row: number, col: number) {
return (row - 1) * 4 + col - 1;
}
</script>
月份选择组件相对简单
- MONTHS 使用 dayjs 获取到所有月份的简写
- 布局中循环 3 行 4 列,使用
getIndex
获取对应的索引 - 点击选择时
handlePick
中拼装 cell 对象,这里只有 date 属性是必须项
YearTable 组件
year-table.vue
<template>
<table :class="n()">
<tbody>
<tr v-for="row in 3" :key="row">
<td
v-for="col in 4"
:key="`${row + '_' + col}`"
@click="handlePick(row, col)"
>
<div :class="[n('cell'), { selected: isSelect(row, col) }]">
<span :class="n('cell-text')">{{ getText(row, col) }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script lang="ts" setup>
import { computed, inject } from "vue";
import { createNamespace } from "@vangle/utils";
import { DatePickerContextKey } from "../date-picker";
import dayjs, { Dayjs } from "dayjs";
defineOptions({
name: "VanPickerPanel",
});
const props = withDefaults(
defineProps<{
date: Dayjs;
}>(),
{
date: () => dayjs(),
}
);
const emit = defineEmits(["pick"]);
const currentYear = computed(() => props.date.year());
const currentNumber = computed(() => props.date.year() % 10);
const datePicker = inject(DatePickerContextKey);
const { n } = createNamespace("year-table");
function handlePick(row: number, col: number) {
const year = getText(row, col);
if (year) {
const cell = {
date: dayjs(props.date.set("year", year)),
};
emit("pick", cell);
}
}
function isSelect(row: number, col: number) {
return datePicker?.date.value.year() === getText(row, col);
}
function getText(row: number, col: number) {
const n = (row - 1) * 4 + col - 1;
if (n >= 10) return;
return currentYear.value + n - currentNumber.value;
}
</script>
思路和 MonthTable 组件差不多,也是 3 行 4 列,提供当前年的 10 个选项
最后
本文拆分为 3 个部分讲解了日期选择组件的封装
第一部分:使用 tooltip 组件声明触发器(输入框)和弹出层(日期面板) 第二部分:由于面板涉及一些公共部分,又将其进行了拆分,将 shortcuts 和切换日期等公共功能放在面板里,而对于日期表格进行了单独的封装 第三部分:分别实现日期、月份、年份等表格展示
标签:picker,const,dayjs,value,props,date,import,选择器 From: https://www.cnblogs.com/wp-leonard/p/17903490.html