v-calendar
Version:
A calendar and date picker plugin for Vue.js.
892 lines (802 loc) • 22.8 kB
text/typescript
import {
type SetupContext,
type ExtractPropTypes,
type PropType,
ref,
computed,
watch,
onMounted,
nextTick,
toRef,
inject,
provide,
} from 'vue';
import Calendar from '../components/Calendar.vue';
import Popover from '../components/Popover.vue';
import { getDefault } from '../utils/defaults';
import type { AttributeConfig } from '../utils/attribute';
import {
type CalendarDay,
getPageAddressForDate,
pageIsBetweenPages,
} from '../utils/page';
import {
isNumber,
isString,
isObject,
isArray,
isDate,
defaultsDeep,
createGuid,
} from '../utils/helpers';
import type {
DatePatch,
DateParts,
DatePartsRules,
} from '../utils/date/helpers';
import type { SimpleDateRange } from '../utils/date/range';
import {
type PopoverOptions,
showPopover as sp,
hidePopover as hp,
togglePopover as tp,
getPopoverEventHandlers,
} from '../utils/popovers';
import { propsDef as basePropsDef, createBase } from './base';
import type { MoveTarget, MoveOptions } from './calendar';
export type DateType = 'date' | 'string' | 'number';
export interface DateConfig {
type: DateType;
rules: DatePartsRules;
mask?: string;
}
const contextKey = '__vc_date_picker_context__';
export type DateModes = 'date' | 'datetime' | 'time';
export type ValueTarget = 'start' | 'end';
export interface UpdateOptions {
config: any;
patch: DatePatch;
debounce: number;
clearIfEqual: boolean;
formatInput: boolean;
hidePopover: boolean;
dragging: boolean;
targetPriority: ValueTarget;
moveToValue: boolean;
}
export interface ModelModifiers {
number?: boolean;
string?: boolean;
range?: boolean;
}
export type DatePickerDate = number | string | Date | null;
export type DatePickerRangeArray = [DatePickerDate, DatePickerDate];
export type DatePickerRangeObject = Partial<{
start: DatePickerDate;
end: DatePickerDate;
}>;
export type DatePickerContext = ReturnType<typeof createDatePicker>;
export type DatePickerProps = Readonly<ExtractPropTypes<typeof propsDef>>;
export const propsDef = {
...basePropsDef,
mode: { type: String, default: 'date' },
modelValue: {
type: [Number, String, Date, Object] as PropType<
number | string | Date | DatePickerRangeObject | null
>,
},
modelModifiers: {
type: Object as PropType<ModelModifiers>,
default: () => ({}),
},
rules: [String, Object] as PropType<'auto' | DatePartsRules>,
is24hr: Boolean,
hideTimeHeader: Boolean,
timeAccuracy: { type: Number, default: 2 },
isRequired: Boolean,
isRange: Boolean,
updateOnInput: {
type: Boolean,
default: () => getDefault('datePicker.updateOnInput'),
},
inputDebounce: {
type: Number,
default: () => getDefault('datePicker.inputDebounce'),
},
popover: {
type: [Boolean, Object] as PropType<boolean | Partial<PopoverOptions>>,
default: true,
},
dragAttribute: Object as PropType<AttributeConfig>,
selectAttribute: Object as PropType<AttributeConfig>,
attributes: [Object, Array],
};
export const emits = [
'update:modelValue',
'drag',
'dayclick',
'daykeydown',
'popover-will-show',
'popover-did-show',
'popover-will-hide',
'popover-did-hide',
];
export function createDatePicker(
props: DatePickerProps,
ctx: SetupContext<string[]>,
) {
const baseCtx = createBase(props);
const { locale, masks, disabledAttribute } = baseCtx;
const { emit } = ctx;
const showCalendar = ref(false);
const datePickerPopoverId = ref(createGuid());
const dateValue = ref<null | Date | SimpleDateRange>(null);
const dragValue = ref<null | SimpleDateRange>(null);
const inputValues = ref<string[]>(['', '']);
const popoverRef = ref<InstanceType<typeof Popover> | null>(null);
const calendarRef = ref<InstanceType<typeof Calendar> | null>(null);
let updateTimeout: undefined | number = undefined;
let dragTrackingValue: null | SimpleDateRange;
let watchValue = true;
// #region Computed
const isRange = computed(() => {
return props.isRange || props.modelModifiers.range === true;
});
const valueStart = computed(() =>
isRange.value && dateValue.value != null
? (dateValue.value as SimpleDateRange).start
: null,
);
const valueEnd = computed(() =>
isRange.value && dateValue.value != null
? (dateValue.value as SimpleDateRange).end
: null,
);
const isDateMode = computed(() => props.mode.toLowerCase() === 'date');
const isDateTimeMode = computed(
() => props.mode.toLowerCase() === 'datetime',
);
const isTimeMode = computed(() => props.mode.toLowerCase() === 'time');
const isDragging = computed(() => !!dragValue.value);
const modelConfig = computed(() => {
let type: DateType = 'date';
if (props.modelModifiers.number) type = 'number';
if (props.modelModifiers.string) type = 'string';
const mask = masks.value.modelValue || 'iso';
return normalizeConfig(<DateConfig>{ type, mask });
});
const dateParts = computed(() =>
getDateParts(dragValue.value ?? dateValue.value),
);
const inputMask = computed(() => {
if (isTimeMode.value) {
return props.is24hr ? masks.value.inputTime24hr : masks.value.inputTime;
}
if (isDateTimeMode.value) {
return props.is24hr
? masks.value.inputDateTime24hr
: masks.value.inputDateTime;
}
return masks.value.input;
});
const inputMaskHasTime = computed(() => /[Hh]/g.test(inputMask.value));
const inputMaskHasDate = computed(() =>
/[dD]{1,2}|Do|W{1,4}|M{1,4}|YY(?:YY)?/g.test(inputMask.value),
);
const inputMaskPatch = computed(() => {
if (inputMaskHasTime.value && inputMaskHasDate.value) {
return 'dateTime';
}
if (inputMaskHasDate.value) return 'date';
if (inputMaskHasTime.value) return 'time';
return undefined;
});
const popover = computed(() => {
const target = popoverRef.value?.$el.previousElementSibling ?? undefined;
return defaultsDeep({}, props.popover, getDefault('datePicker.popover'), {
target,
}) as Partial<PopoverOptions>;
});
const popoverEvents = computed(() =>
getPopoverEventHandlers({
...popover.value,
id: datePickerPopoverId.value,
}),
);
const inputValue = computed(() => {
return isRange.value
? {
start: inputValues.value[0],
end: inputValues.value[1],
}
: inputValues.value[0];
});
const inputEvents = computed(() => {
const events = (['start', 'end'] as const).map(target => ({
input: onInputInput(target),
change: onInputChange(target),
keyup: onInputKeyup,
...(props.popover && popoverEvents.value),
}));
return isRange.value
? {
start: events[0],
end: events[1],
}
: events[0];
});
const selectAttribute = computed(() => {
if (!hasValue(dateValue.value)) return null;
const attribute = {
key: 'select-drag',
...props.selectAttribute,
dates: dateValue.value,
pinPage: true,
};
const { dot, bar, highlight, content } = attribute;
if (!dot && !bar && !highlight && !content) {
attribute.highlight = true;
}
return attribute;
});
const dragAttribute = computed(() => {
if (!isRange.value || !hasValue(dragValue.value)) {
return null;
}
const attribute = {
key: 'select-drag',
...props.dragAttribute,
dates: dragValue.value,
};
const { dot, bar, highlight, content } = attribute;
if (!dot && !bar && !highlight && !content) {
attribute.highlight = {
startEnd: {
fillMode: 'outline',
},
};
}
return attribute;
});
const attributes = computed(() => {
const attrs = isArray(props.attributes) ? [...props.attributes] : [];
if (dragAttribute.value) {
attrs.unshift(dragAttribute.value);
} else if (selectAttribute.value) {
attrs.unshift(selectAttribute.value);
}
return attrs;
});
const rules = computed(() => {
return normalizeConfig(
props.rules === 'auto' ? getAutoRules() : props.rules ?? {},
);
});
// #endregion Computed
function getAutoRules() {
const _rules = {
ms: [0, 999],
sec: [0, 59],
min: [0, 59],
hr: [0, 23],
};
const accuracy = isDateMode.value ? 0 : props.timeAccuracy;
return [0, 1].map(i => {
switch (accuracy) {
case 0:
return {
hours: _rules.hr[i],
minutes: _rules.min[i],
seconds: _rules.sec[i],
milliseconds: _rules.ms[i],
};
case 1:
return {
minutes: _rules.min[i],
seconds: _rules.sec[i],
milliseconds: _rules.ms[i],
};
case 3:
return { milliseconds: _rules.ms[i] };
case 4:
return {};
default:
return { seconds: _rules.sec[i], milliseconds: _rules.ms[i] };
}
});
}
function normalizeConfig<T>(config: T | T[]): T[] {
if (isArray(config)) {
if (config.length === 1) return [config[0], config[0]];
return config;
}
return [config, config];
}
function normalizeDateConfig(
config: Partial<DateConfig> | Partial<DateConfig>[],
): DateConfig[] {
return normalizeConfig(config).map(
(c, i) =>
({
...c,
rules: rules.value[i],
} as DateConfig),
);
}
function hasDateValue(value: unknown) {
if (isNumber(value)) return !isNaN(value);
if (isDate(value)) return !isNaN(value.getTime());
if (isString(value)) return value !== '';
return value != null;
}
function hasValue(value: any) {
if (isRange.value) {
return (
isObject(value) && hasDateValue(value.start) && hasDateValue(value.end)
);
}
return hasDateValue(value);
}
function datesAreEqual(a: any, b: any): boolean {
const aIsDate = isDate(a);
const bIsDate = isDate(b);
if (!aIsDate && !bIsDate) return true;
if (aIsDate !== bIsDate) return false;
return a.getTime() === b.getTime();
}
function valuesAreEqual(a: any, b: any) {
if (isRange.value) {
const aHasValue = hasValue(a);
const bHasValue = hasValue(b);
if (!aHasValue && !bHasValue) return true;
if (aHasValue !== bHasValue) return false;
return datesAreEqual(a.start, b.start) && datesAreEqual(a.end, b.end);
}
return datesAreEqual(a, b);
}
function valueIsDisabled(value: any) {
if (!hasValue(value) || !disabledAttribute.value) return false;
return disabledAttribute.value.intersectsRange(locale.value.range(value));
}
function normalizeValue(
value: any,
config: DateConfig[],
patch: DatePatch,
targetPriority?: ValueTarget,
): Date | SimpleDateRange | null {
if (!hasValue(value)) return null;
if (isRange.value) {
const start = locale.value.toDate(value.start, {
...config[0],
fillDate: valueStart.value ?? undefined,
patch,
});
const end = locale.value.toDate(value.end, {
...config[1],
fillDate: valueEnd.value ?? undefined,
patch,
});
return sortRange({ start, end }, targetPriority);
}
return locale.value.toDateOrNull(value, {
...config[0],
fillDate: dateValue.value as Date,
patch,
});
}
function denormalizeValue(value: any, config: DateConfig[]) {
if (isRange.value) {
if (!hasValue(value)) return null;
return {
start: locale.value.fromDate(value.start, config[0]),
end: locale.value.fromDate(value.end, config[1]),
};
}
return locale.value.fromDate(value, config[0]);
}
function updateValue(
value: any,
opts: Partial<UpdateOptions> = {},
): Promise<ReturnType<typeof forceUpdateValue>> {
clearTimeout(updateTimeout);
return new Promise(resolve => {
const { debounce = 0, ...args } = opts;
if (debounce > 0) {
updateTimeout = window.setTimeout(() => {
resolve(forceUpdateValue(value, args));
}, debounce);
} else {
resolve(forceUpdateValue(value, args));
}
});
}
function forceUpdateValue(
value: any,
{
config = modelConfig.value,
patch = 'dateTime',
clearIfEqual = false,
formatInput: fInput = true,
hidePopover: hPopover = false,
dragging = isDragging.value,
targetPriority,
moveToValue: mValue = false,
}: Partial<UpdateOptions> = {},
) {
// 1. Normalization
const normalizedConfig = normalizeDateConfig(config);
let normalizedValue = normalizeValue(
value,
normalizedConfig,
patch,
targetPriority,
);
// 2a. Validation against disabled dates
const isDisabled = valueIsDisabled(normalizedValue);
if (isDisabled) {
if (dragging) return null;
normalizedValue = dateValue.value;
// Don't allow hiding popover
hPopover = false;
// 2b. Validation against is-required or clearIfEqual
} else if (normalizedValue == null && props.isRequired) {
// Reset to previous value if it was cleared but is required
normalizedValue = dateValue.value;
// 2c. Validation against clearIfEqual
} else if (
// Clear value if same value was passed
normalizedValue != null &&
valuesAreEqual(dateValue.value, normalizedValue) &&
clearIfEqual
) {
normalizedValue = null;
}
// 3. Assignment
const valueRef = dragging ? dragValue : dateValue;
const notify = !valuesAreEqual(valueRef.value, normalizedValue);
valueRef.value = normalizedValue;
// Clear drag value if needed
if (!dragging) dragValue.value = null;
// Denormalize value using the model config
const denormalizedValue = denormalizeValue(
normalizedValue,
modelConfig.value,
);
// 4. Notification
if (notify) {
watchValue = false;
emit(dragging ? 'drag' : 'update:modelValue', denormalizedValue);
nextTick(() => (watchValue = true));
}
// 5. Hide popover if needed
if (hPopover && !dragging) hidePopover();
// 6. Format inputs if needed
if (fInput) formatInput();
// 7. Move to range target if needed
if (mValue) {
nextTick(() => moveToValue(targetPriority ?? 'start'));
}
return denormalizedValue;
}
function formatInput() {
nextTick(() => {
const config = normalizeDateConfig({
type: 'string',
mask: inputMask.value,
});
const value = denormalizeValue(
dragValue.value || dateValue.value,
config,
);
if (isRange.value) {
// @ts-ignore
inputValues.value = [value && value.start, value && value.end];
} else {
inputValues.value = [value as string, ''];
}
});
}
function onInputUpdate(
inputValue: string,
target: ValueTarget,
opts: Partial<UpdateOptions>,
) {
inputValues.value.splice(target === 'start' ? 0 : 1, 1, inputValue);
const value = isRange.value
? {
start: inputValues.value[0],
end: inputValues.value[1] || inputValues.value[0],
}
: inputValue;
const config = {
type: 'string',
mask: inputMask.value,
};
updateValue(value, {
...opts,
config,
patch: inputMaskPatch.value,
targetPriority: target,
moveToValue: true,
});
}
function onInputInput(target: ValueTarget) {
return (e: InputEvent) => {
if (!props.updateOnInput) return;
onInputUpdate((<HTMLInputElement>e.currentTarget).value, target, {
formatInput: false,
hidePopover: false,
debounce: props.inputDebounce,
});
};
}
function onInputChange(target: ValueTarget) {
return (e: InputEvent) => {
onInputUpdate((<HTMLInputElement>e.currentTarget).value, target, {
formatInput: true,
hidePopover: false,
});
};
}
function onInputKeyup(e: KeyboardEvent) {
// Escape key only
if (e.key !== 'Escape') return;
updateValue(dateValue.value, {
formatInput: true,
hidePopover: true,
});
}
function getDateParts(value: any): (DateParts | null)[] {
if (isRange.value) {
return [
value && value.start ? locale.value.getDateParts(value.start) : null,
value && value.end ? locale.value.getDateParts(value.end) : null,
];
}
return [value ? locale.value.getDateParts(value) : null];
}
function cancelDrag() {
dragValue.value = null;
formatInput();
}
function onPopoverBeforeShow(el: HTMLElement) {
emit('popover-will-show', el);
}
function onPopoverAfterShow(el: HTMLElement) {
emit('popover-did-show', el);
}
function onPopoverBeforeHide(el: HTMLElement) {
cancelDrag();
emit('popover-will-hide', el);
}
function onPopoverAfterHide(el: HTMLElement) {
emit('popover-did-hide', el);
}
function handleDayClick(day: CalendarDay) {
const opts: Partial<UpdateOptions> = {
patch: 'date',
formatInput: true,
hidePopover: true,
};
if (isRange.value) {
const dragging = !isDragging.value;
if (dragging) {
dragTrackingValue = { start: day.startDate, end: day.endDate };
} else if (dragTrackingValue != null) {
dragTrackingValue.end = day.date;
}
updateValue(dragTrackingValue, {
...opts,
dragging,
});
} else {
updateValue(day.date, {
...opts,
clearIfEqual: !props.isRequired,
});
}
}
function onDayClick(day: CalendarDay, event: MouseEvent) {
handleDayClick(day);
// Re-emit event
emit('dayclick', day, event);
}
function onDayKeydown(day: CalendarDay, event: KeyboardEvent) {
switch (event.key) {
case ' ':
case 'Enter': {
handleDayClick(day);
event.preventDefault();
break;
}
case 'Escape': {
hidePopover();
}
}
// Re-emit event
emit('daykeydown', day, event);
}
function onDayMouseEnter(day: CalendarDay, event: MouseEvent) {
if (!isDragging.value || dragTrackingValue == null) return;
dragTrackingValue.end = day.date;
updateValue(sortRange(dragTrackingValue), {
patch: 'date',
formatInput: true,
});
}
function showPopover(opts: Partial<PopoverOptions> = {}) {
sp({
...popover.value,
...opts,
isInteractive: true,
id: datePickerPopoverId.value,
});
}
function hidePopover(opts: Partial<PopoverOptions> = {}) {
hp({
hideDelay: 10,
force: true,
...popover.value,
...opts,
id: datePickerPopoverId.value,
});
}
function togglePopover(opts: Partial<PopoverOptions>) {
tp({
...popover.value,
...opts,
isInteractive: true,
id: datePickerPopoverId.value,
});
}
function sortRange(range: any, priority?: ValueTarget) {
const { start, end } = range;
if (start > end) {
switch (priority) {
case 'start':
return { start, end: start };
case 'end':
return { start: end, end };
default:
return { start: end, end: start };
}
}
return { start, end };
}
function getPageForValue(isStart: boolean) {
if (hasValue(dateValue.value)) {
const date = isRange.value
? isStart
? valueStart.value
: valueEnd.value
: dateValue.value;
return getPageAddressForDate(date as Date, 'monthly', locale.value);
}
return null;
}
async function move(target: MoveTarget, opts: Partial<MoveOptions> = {}) {
if (calendarRef.value == null) return false;
return calendarRef.value.move(target, opts);
}
async function moveBy(pages: number, opts: Partial<MoveOptions> = {}) {
if (calendarRef.value == null) return false;
return calendarRef.value.moveBy(pages, opts);
}
async function moveToValue(
target: ValueTarget,
opts: Partial<MoveOptions> = {},
) {
if (calendarRef.value == null) return false;
const { firstPage, lastPage, move } = calendarRef.value;
const start = target !== 'end';
const page = getPageForValue(start);
const position = start ? 1 : -1;
if (!page || pageIsBetweenPages(page, firstPage, lastPage)) return false;
return move(page, {
position,
...opts,
});
}
// #endregion Methods
// #region Watch
watch(
() => props.isRange,
val => {
if (val) {
console.warn(
'The `is-range` prop will be deprecated in future releases. Please use the `range` modifier.',
);
}
},
{ immediate: true },
);
watch(
() => inputMask.value,
() => formatInput(),
);
watch(
() => props.modelValue,
val => {
if (!watchValue) return;
forceUpdateValue(val, {
formatInput: true,
hidePopover: false,
});
},
);
watch(
() => rules.value,
() => {
if (isObject(props.rules)) {
forceUpdateValue(props.modelValue, {
formatInput: true,
hidePopover: false,
});
}
},
);
watch(
() => props.timezone,
() => {
forceUpdateValue(dateValue.value, { formatInput: true });
},
);
// #endregion Watch
// #region Lifecycle
// Set initial date value (no validation applied)
const config = normalizeConfig(modelConfig.value);
dateValue.value = normalizeValue(props.modelValue, config, 'dateTime');
onMounted(() => {
forceUpdateValue(props.modelValue, {
formatInput: true,
hidePopover: false,
});
});
// Created
// Waiting a tick allows calendar to initialize page
nextTick(() => (showCalendar.value = true));
// #endregion Lifecycle
const context = {
...baseCtx,
showCalendar,
datePickerPopoverId,
popoverRef,
popoverEvents,
calendarRef,
isRange,
isTimeMode,
isDateTimeMode,
is24hr: toRef(props, 'is24hr'),
hideTimeHeader: toRef(props, 'hideTimeHeader'),
timeAccuracy: toRef(props, 'timeAccuracy'),
isDragging,
inputValue,
inputEvents,
dateParts,
attributes,
rules,
move,
moveBy,
moveToValue,
updateValue,
showPopover,
hidePopover,
togglePopover,
onDayClick,
onDayKeydown,
onDayMouseEnter,
onPopoverBeforeShow,
onPopoverAfterShow,
onPopoverBeforeHide,
onPopoverAfterHide,
};
provide(contextKey, context);
return context;
}
export function useDatePicker() {
const context = inject<DatePickerContext>(contextKey);
if (context) return context;
throw new Error(
'DatePicker context missing. Please verify this component is nested within a valid context provider.',
);
}