UNPKG

v-calendar

Version:

A calendar and date picker plugin for Vue.js.

780 lines (697 loc) 20.3 kB
import { type PropType, type ExtractPropTypes, computed, ref, provide, onMounted, onUnmounted, watch, inject, watchEffect, } from 'vue'; import { propsDef as basePropsDef, useOrCreateBase } from './base'; import Popover from '../Popover/Popover.vue'; import { type AttributeConfig, Attribute } from '../utils/attribute'; import { type DateSource, addDays, addMonths, addYears, } from '../utils/date/helpers'; import { type DateRangeCell, DateRangeContext } from '../utils/date/range'; import { getDefault } from '../utils/defaults'; import { type CustomElement, createGuid, isBoolean, has, head, last, arrayHasItems, } from '../utils/helpers'; import { type CalendarDay, type CalendarWeek, type Page, type PageAddress, type TitlePosition, pageRangeToArray, pageIsValid, pageIsEqualToPage, pageIsBeforePage, pageIsAfterPage, pageIsBetweenPages, getPageAddressForDate, addPages as _addPages, } from '../utils/page'; import type { PopoverVisibility } from '../utils/popovers'; import { addHorizontalSwipeHandler } from '../utils/touch'; import { skipWatcher, handleWatcher } from '../utils/watchers'; export type CalendarView = 'daily' | 'weekly' | 'monthly'; export type MoveTarget = DateSource | PageAddress; export type MoveTransition = 'none' | 'fade' | 'slide-v' | 'slide-h'; export interface MoveOptions { position: number; view: CalendarView; transition: MoveTransition; force: boolean; fromPage: PageAddress; toPage: PageAddress; } export interface RefreshOptions { page: PageAddress; position: number; force: boolean; transition: MoveTransition; } export type DayCells = Record< number, { day: CalendarDay; cells: DateRangeCell<Attribute>[] } >; export type CalendarProps = Readonly<ExtractPropTypes<typeof propsDef>>; type IContainer = Pick<Element, 'querySelector'> & CustomElement; export type CalendarContext = ReturnType<typeof createCalendar>; export const propsDef = { ...basePropsDef, view: { type: String as PropType<CalendarView>, default: 'monthly', validator(value: string) { return ['daily', 'weekly', 'monthly'].includes(value); }, }, rows: { type: Number, default: 1, }, columns: { type: Number, default: 1, }, step: Number, titlePosition: { type: String as PropType<TitlePosition>, default: () => getDefault('titlePosition') as TitlePosition, }, navVisibility: { type: String as PropType<PopoverVisibility>, default: () => getDefault('navVisibility') as PopoverVisibility, }, showWeeknumbers: [Boolean, String], showIsoWeeknumbers: [Boolean, String], expanded: Boolean, borderless: Boolean, transparent: Boolean, initialPage: Object as PropType<PageAddress>, initialPagePosition: { type: Number, default: 1 }, minPage: Object as PropType<PageAddress>, maxPage: Object as PropType<PageAddress>, transition: String as PropType<MoveTransition>, attributes: Array as PropType<Array<AttributeConfig>>, trimWeeks: Boolean, disablePageSwipe: Boolean, }; export const emitsDef = [ 'dayclick', 'daymouseenter', 'daymouseleave', 'dayfocusin', 'dayfocusout', 'daykeydown', 'weeknumberclick', 'transition-start', 'transition-end', 'did-move', 'update:view', 'update:pages', ]; const contextKey = '__vc_calendar_context__'; export function createCalendar(props: CalendarProps, { emit, slots }: any) { // Reactive refs const containerRef = ref<IContainer | null>(null); const navPopoverRef = ref<typeof Popover | null>(null); const focusedDay = ref<CalendarDay | null>(null); const focusableDay = ref(new Date().getDate()); const inTransition = ref(false); const navPopoverId = ref(createGuid()); const dayPopoverId = ref(createGuid()); const _view = ref(props.view); const _pages = ref<Page[]>([]); const transitionName = ref(''); // Non-reactive util vars let transitionPromise: any = null; let removeHandlers: any = null; // #region Computed const { theme, color, displayMode, locale, masks, disabledAttribute, disabledDates, } = useOrCreateBase(props); const count = computed(() => props.rows * props.columns); const step = computed(() => props.step || count.value); const firstPage = computed(() => head(_pages.value) ?? null); const lastPage = computed(() => last(_pages.value) ?? null); const minPage = computed( () => props.minPage || (props.minDate ? getDateAddress(props.minDate) : null), ); const maxPage = computed( () => props.maxPage || (props.maxDate ? getDateAddress(props.maxDate) : null), ); const navVisibility = computed(() => props.navVisibility); const showWeeknumbers = computed(() => !!props.showWeeknumbers); const showIsoWeeknumbers = computed(() => !!props.showIsoWeeknumbers); const isMonthly = computed(() => _view.value === 'monthly'); const isWeekly = computed(() => _view.value === 'weekly'); const isDaily = computed(() => _view.value === 'daily'); // #endregion Computed // #region Methods const onTransitionBeforeEnter = () => { inTransition.value = true; emit('transition-start'); }; const onTransitionAfterEnter = () => { inTransition.value = false; emit('transition-end'); if (transitionPromise) { transitionPromise.resolve(true); transitionPromise = null; } }; const addPages = ( address: PageAddress, count: number, view = _view.value, ) => { return _addPages(address, count, view, locale.value); }; const getDateAddress = (date: DateSource) => { return getPageAddressForDate(date, _view.value, locale.value); }; const refreshDisabled = (day: CalendarDay) => { if (!disabledAttribute.value || !attributeContext.value) return; day.isDisabled = attributeContext.value.cellExists( disabledAttribute.value.key, day.dayIndex, ); }; const refreshFocusable = (day: CalendarDay) => { day.isFocusable = day.inMonth && day.day === focusableDay.value; }; const forDays = (pages: Page[], fn: (day: CalendarDay) => boolean | void) => { for (const page of pages) { for (const day of page.days) { if (fn(day) === false) return; } } }; const days = computed(() => _pages.value.reduce((result: CalendarDay[], page: Page) => { result.push(...page.viewDays); return result; }, <CalendarDay[]>[]), ); const attributes = computed(() => { const result: Attribute[] = []; (props.attributes || []).forEach((attr, i) => { if (!attr || !attr.dates) return; const key = has(attr, 'key') ? attr.key : i; const order = attr.order || 0; result.push( new Attribute( { ...attr, key, order, }, theme.value, locale.value, ), ); }); if (disabledAttribute.value) { result.push(disabledAttribute.value); } return result; }); const hasAttributes = computed(() => arrayHasItems(attributes.value)); const attributeContext = computed(() => { const ctx = new DateRangeContext(); attributes.value.forEach(attr => { attr.ranges.forEach(range => { ctx.render(attr, range, days.value); }); }); return ctx; }); const dayCells = computed(() => { return days.value.reduce((result, day) => { result[day.dayIndex] = { day, cells: [] }; result[day.dayIndex].cells.push(...attributeContext.value.getCells(day)); return result; }, {} as DayCells); }); const getWeeknumberPosition = (column: number, columnFromEnd: number) => { const showWeeknumbers = props.showWeeknumbers || props.showIsoWeeknumbers; if (showWeeknumbers == null) return ''; if (isBoolean(showWeeknumbers)) { return showWeeknumbers ? 'left' : ''; } if (showWeeknumbers.startsWith('right')) { return columnFromEnd > 1 ? 'right' : showWeeknumbers; } return column > 1 ? 'left' : showWeeknumbers; }; const getPageForAttributes = () => { if (!hasAttributes.value) return null; const attr = attributes.value.find(attr => attr.pinPage) || attributes.value[0]; if (!attr || !attr.hasRanges) return null; const [range] = attr.ranges; const date = range.start?.date || range.end?.date; return date ? getDateAddress(date) : null; }; const getDefaultInitialPage = () => { // 1. Try existing first page if (pageIsValid(firstPage.value)) return firstPage.value as PageAddress; // 2. Try the first attribute const page = getPageForAttributes(); if (pageIsValid(page)) return page as PageAddress; // 3. Use today's page return getDateAddress(new Date()); }; const getTargetPageRange = ( page: PageAddress, opts: Partial<MoveOptions> = {}, ) => { const { view = _view.value, position = 1, force } = opts; const pagesToAdd = position > 0 ? 1 - position : -(count.value + position); let fromPage = addPages(page, pagesToAdd, view); let toPage = addPages(fromPage!, count.value - 1, view); // Adjust range for min/max if not forced if (!force) { if (pageIsBeforePage(fromPage, minPage.value)) { fromPage = minPage.value!; } else if (pageIsAfterPage(toPage, maxPage.value)) { fromPage = addPages(maxPage.value!, 1 - count.value); } toPage = addPages(fromPage!, count.value - 1); } return { fromPage, toPage }; }; const getPageTransition = ( oldPage: Page, newPage: Page, defaultTransition = '', ) => { if (defaultTransition === 'none' || defaultTransition === 'fade') return defaultTransition; // Moving to a different view if (oldPage?.view !== newPage?.view) return 'fade'; // Moving to a previous page const moveNext = pageIsAfterPage(newPage, oldPage); const movePrev = pageIsBeforePage(newPage, oldPage); if (!moveNext && !movePrev) { return 'fade'; } // Vertical slide if (defaultTransition === 'slide-v') { return movePrev ? 'slide-down' : 'slide-up'; } // Horizontal slide return movePrev ? 'slide-right' : 'slide-left'; }; const refreshPages = (opts: Partial<RefreshOptions> = {}) => { return new Promise((resolve, reject) => { const { position = 1, force = false, transition } = opts; const page = pageIsValid(opts.page) ? opts.page! : getDefaultInitialPage(); const { fromPage } = getTargetPageRange(page, { position, force, }); // Create the new pages const pages: Page[] = []; for (let i = 0; i < count.value; i++) { const newPage = addPages(fromPage!, i); const position = i + 1; const row = Math.ceil(position / props.columns); const rowFromEnd = props.rows - row + 1; const column = position % props.columns || props.columns; const columnFromEnd = props.columns - column + 1; const weeknumberPosition = getWeeknumberPosition(column, columnFromEnd); pages.push( locale.value.getPage({ ...newPage, view: _view.value, titlePosition: props.titlePosition, trimWeeks: props.trimWeeks, position, row, rowFromEnd, column, columnFromEnd, showWeeknumbers: showWeeknumbers.value, showIsoWeeknumbers: showIsoWeeknumbers.value, weeknumberPosition, }), ); } // Assign the transition transitionName.value = getPageTransition( _pages.value[0], pages[0], transition, ); // Assign the new pages _pages.value = pages; // Cache or resolve transition promise if (transitionName.value && transitionName.value !== 'none') { transitionPromise = { resolve, reject, }; } else { resolve(true); } }); }; const targetBy = (pages: number) => { const fromPage = firstPage.value ?? getDateAddress(new Date()); return addPages(fromPage, pages); }; const canMove = (target: MoveTarget, opts: Partial<MoveOptions> = {}) => { const page = pageIsValid(target as PageAddress) ? (target as Page) : getDateAddress(target as DateSource); // Calculate new page range without adjusting to min/max Object.assign( opts, getTargetPageRange(page, { ...opts, force: true, }), ); // Verify we can move to any pages in the target range const pagesInRange = pageRangeToArray( opts.fromPage!, opts.toPage!, _view.value, locale.value, ).map(p => pageIsBetweenPages(p, minPage.value, maxPage.value)); return pagesInRange.every(val => val); }; const canMoveBy = (pages: number, opts: Partial<MoveOptions> = {}) => { return canMove(targetBy(pages), opts); }; const canMovePrev = computed(() => canMoveBy(-step.value)); const canMoveNext = computed(() => canMoveBy(step.value)); const move = async (target: MoveTarget, opts: Partial<MoveOptions> = {}) => { // Return if we can't move to this page if (!opts.force && !canMove(target, opts)) return false; // Move to new `fromPage` if it's different from the current one if (opts.fromPage && !pageIsEqualToPage(opts.fromPage, firstPage.value)) { // Hide nav popover for good measure if (navPopoverRef.value) { navPopoverRef.value.hide({ hideDelay: 0 }); } // Quietly change view if needed if (opts.view) { skipWatcher('view', 10); _view.value = opts.view; } await refreshPages({ ...opts, page: opts.fromPage, position: 1, force: true, }); emit('did-move', _pages.value); } return true; }; const moveBy = (pages: number, opts: Partial<MoveOptions> = {}) => { return move(targetBy(pages), opts); }; const movePrev = () => { return moveBy(-step.value); }; const moveNext = () => { return moveBy(step.value); }; const tryFocusDate = (date: Date) => { const inMonth = isMonthly.value ? '.in-month' : ''; const daySelector = `.id-${locale.value.getDayId(date)}${inMonth}`; const selector = `${daySelector}.vc-focusable, ${daySelector} .vc-focusable`; const el = containerRef.value; if (el) { const focusableEl = el.querySelector(selector) as HTMLElement; if (focusableEl) { focusableEl.focus(); return true; } } return false; }; const focusDate = async (date: Date, opts: Partial<MoveOptions> = {}) => { if (tryFocusDate(date)) return true; // Move to the given date await move(date, opts); return tryFocusDate(date); }; const onDayClick = (day: CalendarDay, event: MouseEvent) => { focusableDay.value = day.day; emit('dayclick', day, event); }; const onDayMouseenter = (day: CalendarDay, event: MouseEvent) => { emit('daymouseenter', day, event); }; const onDayMouseleave = (day: CalendarDay, event: MouseEvent) => { emit('daymouseleave', day, event); }; const onDayFocusin = (day: CalendarDay, event: FocusEvent | null) => { focusableDay.value = day.day; focusedDay.value = day; day.isFocused = true; emit('dayfocusin', day, event); }; const onDayFocusout = (day: CalendarDay, event: FocusEvent) => { focusedDay.value = null; day.isFocused = false; emit('dayfocusout', day, event); }; const onDayKeydown = (day: CalendarDay, event: KeyboardEvent) => { emit('daykeydown', day, event); const date = day.noonDate; let newDate = null; switch (event.key) { case 'ArrowLeft': { // Move to previous day newDate = addDays(date, -1); break; } case 'ArrowRight': { // Move to next day newDate = addDays(date, 1); break; } case 'ArrowUp': { // Move to previous week newDate = addDays(date, -7); break; } case 'ArrowDown': { // Move to next week newDate = addDays(date, 7); break; } case 'Home': { // Move to first weekday position newDate = addDays(date, -day.weekdayPosition + 1); break; } case 'End': { // Move to last weekday position newDate = addDays(date, day.weekdayPositionFromEnd); break; } case 'PageUp': { if (event.altKey) { // Move to previous year w/ Alt/Option key newDate = addYears(date, -1); } else { // Move to previous month newDate = addMonths(date, -1); } break; } case 'PageDown': { if (event.altKey) { // Move to next year w/ Alt/Option key newDate = addYears(date, 1); } else { // Move to next month newDate = addMonths(date, 1); } break; } } if (newDate) { event.preventDefault(); focusDate(newDate).catch(); } }; const onKeydown = (event: KeyboardEvent) => { const day = focusedDay.value; if (day != null) { onDayKeydown(day, event); } }; const onWeeknumberClick = (week: CalendarWeek, event: MouseEvent) => { emit('weeknumberclick', week, event); }; // #endregion Methods // #region Lifecycle methods // Created refreshPages({ page: props.initialPage, position: props.initialPagePosition, }); // Mounted onMounted(() => { if (!props.disablePageSwipe && containerRef.value) { // Add swipe handler to move to next and previous pages removeHandlers = addHorizontalSwipeHandler( containerRef.value, ({ toLeft = false, toRight = false }) => { if (toLeft) { moveNext(); } else if (toRight) { movePrev(); } }, getDefault('touch'), ); } }); // Unmounted onUnmounted(() => { _pages.value = []; if (removeHandlers) removeHandlers(); }); // #endregion Lifecycle methods // #region Watch watch( () => locale.value, () => { refreshPages(); }, ); watch( () => count.value, () => refreshPages(), ); watch( () => props.view, () => (_view.value = props.view), ); watch( () => _view.value, () => { handleWatcher('view', () => { refreshPages(); }); emit('update:view', _view.value); }, ); watch( () => focusableDay.value, () => { forDays(_pages.value, day => refreshFocusable(day)); }, ); watchEffect(() => { emit('update:pages', _pages.value); // Refresh state for days forDays(_pages.value, day => { // Refresh disabled state refreshDisabled(day); // Refresh focusable state refreshFocusable(day); }); }); // #endregion Watch const context = { emit, slots, containerRef, navPopoverRef, focusedDay, inTransition, navPopoverId, dayPopoverId, view: _view, pages: _pages, transitionName, theme, color, displayMode, locale, masks, attributes, disabledAttribute, disabledDates, attributeContext, days, dayCells, count, step, firstPage, lastPage, canMovePrev, canMoveNext, minPage, maxPage, isMonthly, isWeekly, isDaily, navVisibility, showWeeknumbers, showIsoWeeknumbers, getDateAddress, canMove, canMoveBy, move, moveBy, movePrev, moveNext, onTransitionBeforeEnter, onTransitionAfterEnter, tryFocusDate, focusDate, onKeydown, onDayKeydown, onDayClick, onDayMouseenter, onDayMouseleave, onDayFocusin, onDayFocusout, onWeeknumberClick, }; provide(contextKey, context); return context; } export function useCalendar(): CalendarContext { const context = inject<CalendarContext>(contextKey); if (context) return context; throw new Error( 'Calendar context missing. Please verify this component is nested within a valid context provider.', ); }