UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

1,042 lines (971 loc) 33.7 kB
import { defineComponent, h, nextTick, PropType, ref, watch } from 'vue' import { convertToDateObject, createGroupsInArray, getCalendarDate, getDateBySelectionType, getMonthDetails, getMonthsNames, getYears, isDateDisabled, isDateInRange, isDateSelected, isDisableDateInRange, isMonthDisabled, isMonthInRange, isMonthSelected, isToday, isSameDateAs, isYearDisabled, isYearInRange, isYearSelected, getSelectableDates, setTimeFromDate, } from './utils' import type { DisabledDate, SelectionTypes, ViewTypes } from './types' const CCalendar = defineComponent({ name: 'CCalendar', props: { /** * A string that provides an accessible label for the button that navigates to the next month in the calendar. This label is read by screen readers to describe the action associated with the button. * * @since 5.4.0 */ ariaNavNextMonthLabel: { type: String, default: 'Next month', }, /** * A string that provides an accessible label for the button that navigates to the next year in the calendar. This label is intended for screen readers to help users understand the button's functionality. * * @since 5.4.0 */ ariaNavNextYearLabel: { type: String, default: 'Next year', }, /** * A string that provides an accessible label for the button that navigates to the previous month in the calendar. Screen readers will use this label to explain the purpose of the button. * * @since 5.4.0 */ ariaNavPrevMonthLabel: { type: String, default: 'Previous month', }, /** * A string that provides an accessible label for the button that navigates to the previous year in the calendar. This label helps screen reader users understand the button's function. * * @since 5.4.0 */ ariaNavPrevYearLabel: { type: String, default: 'Previous year', }, /** * Default date of the component */ calendarDate: null as unknown as PropType<Date | string | null>, /** * The number of calendars that render on desktop devices. */ calendars: { type: Number, default: 1, }, /** * Set the format of day name. * * @default 'numeric' * @since 4.6.0 */ dayFormat: { type: [Function, String], default: 'numeric', required: false, validator: (value: string) => { if (typeof value === 'string') { return ['numeric', '2-digit'].includes(value) } if (typeof value === 'function') { return true } if (typeof value === 'function') { return true } return false }, }, /** * Specify the list of dates that cannot be selected. */ disabledDates: { type: [Array, Date, Function] as PropType<DisabledDate | DisabledDate[]>, }, /** * Initial selected to date (range). */ endDate: null as unknown as PropType<Date | string | null>, /** * Sets the day of start week. * - 0 - Sunday, * - 1 - Monday, * - 2 - Tuesday, * - 3 - Wednesday, * - 4 - Thursday, * - 5 - Friday, * - 6 - Saturday, */ firstDayOfWeek: { type: Number, default: 1, }, /** * Sets the default locale for components. If not set, it is inherited from the navigator.language. */ locale: { type: String, default: 'default', }, /** * Max selectable date. */ maxDate: null as unknown as PropType<Date | string | null>, /** * Min selectable date. */ minDate: null as unknown as PropType<Date | string | null>, /** * Show arrows navigation. */ navigation: { type: Boolean, default: true, }, /** * Reorder year-month navigation, and render year first. * * @since 4.6.0 */ navYearFirst: Boolean, /** * Allow range selection. */ range: Boolean, /** * Toggle select mode between start and end date. */ selectEndDate: Boolean, /** * Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. * * @since 4.9.0 */ selectAdjacementDays: Boolean, /** * Specify the type of date selection as day, week, month, or year. * * @since 5.0.0 */ selectionType: { type: String as PropType<SelectionTypes>, default: 'day', validator: (value: SelectionTypes) => ['day', 'week', 'month', 'year'].includes(value), }, /** * Set whether to display dates in adjacent months (non-selectable) at the start and end of the current month. * * @since 4.9.0 */ showAdjacementDays: { type: Boolean, default: true, }, /** * Set whether to display week numbers in the calendar. * * @since 5.0.0 */ showWeekNumber: Boolean, /** * Initial selected date. */ startDate: null as unknown as PropType<Date | string | null>, /** * Set length or format of day name. * * @type number | 'long' | 'narrow' | 'short' */ weekdayFormat: { type: [Function, Number, String], default: 2, validator: (value: string | number) => { if (typeof value === 'string') { return ['long', 'narrow', 'short'].includes(value) } if (typeof value === 'number') { return true } if (typeof value === 'function') { return true } return false }, }, /** * Label displayed over week numbers in the calendar. * * @since 5.0.0 */ weekNumbersLabel: String, }, emits: [ /** * Callback fired when the user hovers over the calendar cell. * * @property {Date | null} date */ 'date-hover', /** * Callback fired when the calendar date changed. * * @property {Date | null} date */ 'calendar-date-change', /** * Callback fired when the start date changed. * * @property {Date | null} date */ 'start-date-change', /** * Callback fired when the end date changed. * * @property {Date | null} date */ 'end-date-change', ], setup(props, { slots, emit }) { const calendarRef = ref<HTMLDivElement>() const calendarDate = ref( props.calendarDate ? convertToDateObject(props.calendarDate, props.selectionType) : props.startDate ? convertToDateObject(props.startDate, props.selectionType) : new Date() ) const startDate = ref<Date | null>( props.startDate ? convertToDateObject(props.startDate, props.selectionType) : null ) const endDate = ref( props.endDate ? convertToDateObject(props.endDate, props.selectionType) : null ) const hoverDate = ref<Date | null>(null) const maxDate = ref( props.maxDate ? convertToDateObject(props.maxDate, props.selectionType) : null ) const minDate = ref( props.minDate ? convertToDateObject(props.minDate, props.selectionType) : null ) const selectEndDate = ref(props.selectEndDate) const view = ref<ViewTypes>('days') watch( () => props.selectionType, () => { const viewMap = { day: 'days', week: 'days', month: 'months', year: 'years', } view.value = (viewMap[props.selectionType] as ViewTypes) || 'days' }, { immediate: true } ) watch( () => props.calendarDate, () => { if (props.calendarDate === null) { calendarDate.value = new Date() return } if (props.calendarDate) { calendarDate.value = new Date(props.calendarDate) } } ) watch( () => props.startDate, () => { const date = props.startDate ? convertToDateObject(props.startDate, props.selectionType) : null if (!isSameDateAs(date, startDate.value)) { startDate.value = date } } ) watch( () => props.endDate, () => { const date = props.endDate ? convertToDateObject(props.endDate, props.selectionType) : null if (!isSameDateAs(date, endDate.value)) { endDate.value = date } } ) watch( () => props.maxDate, () => { maxDate.value = props.maxDate ? convertToDateObject(props.maxDate, props.selectionType) : null } ) watch( () => props.minDate, () => { minDate.value = props.minDate ? convertToDateObject(props.minDate, props.selectionType) : null } ) watch( () => props.selectEndDate, () => { selectEndDate.value = props.selectEndDate } ) watch(startDate, () => { emit('start-date-change', getDateBySelectionType(startDate.value, props.selectionType)) }) watch(endDate, () => { emit('end-date-change', getDateBySelectionType(endDate.value, props.selectionType)) }) const setCalendarPage = (years: number, months = 0, setMonth?: number) => { if (calendarDate.value === null) { return } const year = calendarDate.value.getFullYear() const month = calendarDate.value.getMonth() const d = new Date(year, month, 1) if (years) { d.setFullYear(d.getFullYear() + years) } if (months) { d.setMonth(d.getMonth() + months) } if (typeof setMonth === 'number') { d.setMonth(setMonth) } calendarDate.value = d emit('calendar-date-change', d) } const handleCalendarClick = (date: Date, index?: number) => { const _date = new Date(date) if (view.value === 'days') { calendarDate.value = index ? new Date(_date.setMonth(_date.getMonth() - index)) : _date } if (view.value === 'months' && props.selectionType !== 'month') { calendarDate.value = index ? new Date(_date.setMonth(_date.getMonth() - index)) : _date view.value = 'days' return } if (view.value === 'years' && props.selectionType !== 'year') { calendarDate.value = index ? new Date(_date.setFullYear(_date.getFullYear() - index)) : _date view.value = 'months' return } // Allow to change the calendarDate but not startDate or endDate if (isDateDisabled(date, minDate.value, maxDate.value, props.disabledDates)) { return } if (props.range) { if (selectEndDate.value) { selectEndDate.value = false if (startDate.value && startDate.value > date) { startDate.value = null endDate.value = null return } if (isDisableDateInRange(startDate.value, date, props.disabledDates)) { startDate.value = null endDate.value = null return } endDate.value = setTimeFromDate(date, endDate.value) return } if (endDate.value && endDate.value < date) { startDate.value = null endDate.value = null return } if (isDisableDateInRange(date, endDate.value, props.disabledDates)) { startDate.value = null endDate.value = null return } selectEndDate.value = true startDate.value = setTimeFromDate(date, startDate.value) return } startDate.value = setTimeFromDate(date, startDate.value) } const handleCalendarKeyDown = (event: KeyboardEvent, date: Date, index?: number) => { if (event.code === 'Space' || event.key === 'Enter') { event.preventDefault() const _view = view.value handleCalendarClick(date, index) if ( (_view === 'months' && props.selectionType !== 'month') || (_view === 'years' && props.selectionType !== 'year') ) { nextTick(() => { const list: HTMLElement[] = getSelectableDates(calendarRef.value as HTMLDivElement) list[0]?.focus() }) } } if ( event.key === 'ArrowRight' || event.key === 'ArrowLeft' || event.key === 'ArrowUp' || event.key === 'ArrowDown' ) { event.preventDefault() if ( maxDate.value && date >= maxDate.value && (event.key === 'ArrowRight' || event.key === 'ArrowDown') ) { return } if ( minDate.value && date <= minDate.value && (event.key === 'ArrowLeft' || event.key === 'ArrowUp') ) { return } let element = event.target as HTMLElement if (props.selectionType === 'week' && element.tabIndex === -1) { element = element.closest('tr[tabindex="0"]') as HTMLElement } const list: HTMLElement[] = getSelectableDates(calendarRef.value as HTMLDivElement) const index = list.indexOf(element) const first = index === 0 const last = index === list.length - 1 const toBoundary = { start: index, end: list.length - (index + 1), } const gap = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: props.selectionType === 'week' && view.value === 'days' ? -1 : view.value === 'days' ? -7 : -3, ArrowDown: props.selectionType === 'week' && view.value === 'days' ? 1 : view.value === 'days' ? 7 : 3, } if ( (event.key === 'ArrowRight' && last) || (event.key === 'ArrowDown' && toBoundary['end'] < gap['ArrowDown']) || (event.key === 'ArrowLeft' && first) || (event.key === 'ArrowUp' && toBoundary['start'] < Math.abs(gap['ArrowUp'])) ) { if (view.value === 'days') { setCalendarPage(0, event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1) } if (view.value === 'months') { setCalendarPage(event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1) } if (view.value === 'years') { setCalendarPage(event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 10 : -10) } setTimeout(() => { const _list: HTMLElement[] = getSelectableDates( element.parentNode?.parentNode as HTMLDivElement ) if (_list.length > 0 && event.key === 'ArrowRight') { _list[0].focus() } if (_list.length > 0 && event.key === 'ArrowLeft') { _list.at(-1)?.focus() } if (_list.length > 0 && event.key === 'ArrowDown') { _list[gap['ArrowDown'] - (list.length - index)].focus() } if (_list.length > 0 && event.key === 'ArrowUp') { _list[_list.length - (Math.abs(gap['ArrowUp']) + 1 - (index + 1))].focus() } }, 1) return } if (list[index + gap[event.key]].tabIndex === 0) { list[index + gap[event.key]].focus() return } for ( let i = index; i < list.length; event.key === 'ArrowRight' || event.key === 'ArrowDown' ? i++ : i-- ) { if (list[i + gap[event.key]].tabIndex === 0) { list[i + gap[event.key]].focus() break } } } } const handleCalendarMouseEnter = (date: Date) => { if (isDateDisabled(date, minDate.value, maxDate.value, props.disabledDates)) { return } date = setTimeFromDate(date, selectEndDate.value ? endDate.value : startDate.value) as Date hoverDate.value = date emit('date-hover', getDateBySelectionType(date, props.selectionType)) } const handleCalendarMouseLeave = () => { hoverDate.value = null emit('date-hover', null) } const handleNavigationOnClick = (direction: string, double = false) => { if (direction === 'prev') { if (double) { setCalendarPage(view.value === 'years' ? -10 : -1) return } if (view.value !== 'days') { setCalendarPage(-1) return } setCalendarPage(0, -1) return } if (direction === 'next') { if (double) { setCalendarPage(view.value === 'years' ? 10 : 1) return } if (view.value !== 'days') { setCalendarPage(1) return } setCalendarPage(0, 1) return } } const Calendar = (_calendarDate: Date) => { const monthDetails = getMonthDetails( _calendarDate.getFullYear(), _calendarDate.getMonth(), props.firstDayOfWeek ) const listOfMonths = createGroupsInArray(getMonthsNames(props.locale), 4) const listOfYears = createGroupsInArray(getYears(_calendarDate.getFullYear()), 4) const weekDays = monthDetails[0].days return h('table', {}, [ view.value === 'days' && h( 'thead', {}, h('tr', {}, [ props.showWeekNumber && h( 'th', { class: 'calendar-cell' }, h('div', { class: 'calendar-header-cell-inner' }, props.weekNumbersLabel) ), weekDays.map(({ date }: { date: Date }) => { return h( 'th', { class: 'calendar-cell', abbr: date.toLocaleDateString(props.locale, { weekday: 'long' }), }, h( 'div', { class: 'calendar-header-cell-inner', }, typeof props.weekdayFormat === 'function' ? props.weekdayFormat(date) : typeof props.weekdayFormat === 'string' ? date.toLocaleDateString(props.locale, { weekday: <'long' | 'narrow' | 'short'>props.weekdayFormat, }) : date .toLocaleDateString(props.locale, { weekday: 'long' }) .slice(0, props.weekdayFormat) ) ) }), ]) ), h('tbody', {}, [ view.value === 'days' && monthDetails.map(({ week, days }) => { const { date } = days[0] const isDisabled = isDateDisabled( date, minDate.value, maxDate.value, props.disabledDates ) const isSelected = isDateSelected(date, startDate.value, endDate.value) return h( 'tr', { class: [ 'calendar-row', props.selectionType === 'week' && { disabled: isDisabled, range: props.selectionType === 'week' && isDateInRange(date, startDate.value, endDate.value), 'range-hover': props.selectionType === 'week' && hoverDate.value && selectEndDate.value ? isDateInRange(date, startDate.value, hoverDate.value) : isDateInRange(date, hoverDate.value, endDate.value), selected: isSelected, }, ], ...(props.selectionType === 'week' && { tabindex: !isDisabled ? 0 : -1, ...(isSelected && { 'aria-selected': true }), ...(!isDisabled && { onBlur: () => handleCalendarMouseLeave(), onClick: () => handleCalendarClick(date), onFocus: () => handleCalendarMouseEnter(date), onKeydown: (event: KeyboardEvent) => handleCalendarKeyDown(event, date), onMouseenter: () => handleCalendarMouseEnter(date), onMouseleave: () => handleCalendarMouseLeave(), }), }), }, [ props.showWeekNumber && h('th', { class: 'calendar-cell-week-number' }, week.number), days.map(({ date, month }: { date: Date; month: string }) => { const isDisabled = isDateDisabled( date, minDate.value, maxDate.value, props.disabledDates ) const isSelected = isDateSelected(date, startDate.value, endDate.value) return month === 'current' || props.showAdjacementDays ? h( 'td', { class: [ 'calendar-cell', { ...(props.selectionType === 'day' && { clickable: month !== 'current' && props.selectAdjacementDays, disabled: isDisabled, 'range-hover': month === 'current' && (hoverDate.value && selectEndDate.value ? isDateInRange(date, startDate.value, hoverDate.value) : isDateInRange(date, hoverDate.value, endDate.value)), range: month === 'current' && isDateInRange(date, startDate.value, endDate.value), selected: isSelected, }), [month]: true, today: month === 'current' && isToday(date), }, ], tabindex: props.selectionType === 'day' && (month === 'current' || props.selectAdjacementDays) && !isDisabled ? 0 : -1, title: date.toLocaleDateString(props.locale), ...(isSelected && { 'aria-selected': true }), ...(props.selectionType === 'day' && (month === 'current' || props.selectAdjacementDays) && { onBlur: () => handleCalendarMouseLeave(), onClick: () => handleCalendarClick(date), onFocus: () => handleCalendarMouseEnter(date), onKeydown: (event: KeyboardEvent) => handleCalendarKeyDown(event, date), onMouseenter: () => handleCalendarMouseEnter(date), onMouseleave: () => handleCalendarMouseLeave(), }), ...(month !== 'current' && !props.selectAdjacementDays && { onMouseenter: () => handleCalendarMouseLeave(), }), }, h( 'div', { class: 'calendar-cell-inner', }, typeof props.dayFormat === 'function' ? props.dayFormat(date) : date.toLocaleDateString(props.locale, { day: <'numeric' | '2-digit'>props.dayFormat, }) ) ) : h('td') }), ] ) }), view.value === 'months' && listOfMonths.map((row, index) => { return h( 'tr', {}, row.map((month, idx) => { const monthNumber = index * 3 + idx const date = new Date(_calendarDate.getFullYear(), monthNumber, 1) const isDisabled = isMonthDisabled( date, minDate.value, maxDate.value, props.disabledDates ) const isSelected = isMonthSelected(date, startDate.value, endDate.value) return h( 'td', { class: [ 'calendar-cell', { disabled: isDisabled, selected: isSelected, 'range-hover': props.selectionType === 'month' && (hoverDate.value && selectEndDate.value ? isMonthInRange(date, startDate.value, hoverDate.value) : isMonthInRange(date, hoverDate.value, endDate.value)), range: isMonthInRange(date, startDate.value, endDate.value), }, ], tabindex: isDisabled ? -1 : 0, ...(isSelected && { 'aria-selected': true }), ...(!isDisabled && { onBlur: () => handleCalendarMouseLeave(), onClick: () => handleCalendarClick(date), onFocus: () => handleCalendarMouseEnter(date), onKeydown: (event: KeyboardEvent) => handleCalendarKeyDown(event, date), onMouseenter: () => handleCalendarMouseEnter(date), onMouseleave: () => handleCalendarMouseLeave(), }), }, h('div', { class: 'calendar-cell-inner' }, month) ) }) ) }), view.value === 'years' && listOfYears.map((row) => { return h( 'tr', {}, row.map((year) => { const date = new Date(year, 0, 1) const isDisabled = isYearDisabled( date, minDate.value, maxDate.value, props.disabledDates ) const isSelected = isYearSelected(date, startDate.value, endDate.value) return h( 'td', { class: [ 'calendar-cell year', { disabled: isDisabled, selected: isSelected, 'range-hover': props.selectionType === 'year' && (hoverDate.value && selectEndDate.value ? isYearInRange(date, startDate.value, hoverDate.value) : isYearInRange(date, hoverDate.value, endDate.value)), range: isYearInRange(date, startDate.value, endDate.value), }, ], tabindex: isDisabled ? -1 : 0, ...(isSelected && { 'aria-selected': true }), ...(!isDisabled && { onBlur: () => handleCalendarMouseLeave(), onClick: () => handleCalendarClick(date), onFocus: () => handleCalendarMouseEnter(date), onKeydown: (event: KeyboardEvent) => handleCalendarKeyDown(event, date), onMouseenter: () => handleCalendarMouseEnter(date), onMouseleave: () => handleCalendarMouseLeave(), }), }, h('div', { class: 'calendar-cell-inner' }, year) ) }) ) }), ]), ]) } const Navigation = (_calendarDate: Date) => { return h('div', { class: 'calendar-nav' }, [ props.navigation && h( 'div', { class: 'calendar-nav-prev', }, [ h( 'button', { type: 'button', class: 'calendar-nav-btn', 'aria-label': props.ariaNavPrevYearLabel, onClick: () => handleNavigationOnClick('prev', true), }, /** * @slot Location for double previous icon. */ slots.navPrevDoubleIcon ? slots.navPrevDoubleIcon() : h('span', { class: 'calendar-nav-icon calendar-nav-icon-double-prev' }) ), view.value === 'days' && h( 'button', { type: 'button', class: 'calendar-nav-btn', 'aria-label': props.ariaNavPrevMonthLabel, onClick: () => handleNavigationOnClick('prev'), }, /** * @slot Location for previous icon. */ slots.navPrevIcon ? slots.navPrevIcon() : h('span', { class: 'calendar-nav-icon calendar-nav-icon-prev' }) ), ] ), h( 'div', { class: 'calendar-nav-date', 'aria-live': 'polite', ...(props.navYearFirst && { style: { display: 'flex', justifyContent: 'center' } }), }, [ view.value === 'days' && h( 'button', { type: 'button', class: 'calendar-nav-btn', onClick: () => { if (props.navigation) view.value = 'months' }, }, _calendarDate.toLocaleDateString(props.locale, { month: 'long' }) ), h( 'button', { type: 'button', class: 'calendar-nav-btn', onClick: () => { if (props.navigation) view.value = 'years' }, ...(props.navYearFirst && { style: { order: '-1' } }), }, _calendarDate.toLocaleDateString(props.locale, { year: 'numeric' }) ), ] ), props.navigation && h( 'div', { class: 'calendar-nav-next', }, [ view.value === 'days' && h( 'button', { type: 'button', class: 'calendar-nav-btn', 'aria-label': props.ariaNavNextMonthLabel, onClick: () => handleNavigationOnClick('next'), }, /** * @slot Location for next icon. */ slots.navNextIcon ? slots.navNextIcon() : h('span', { class: 'calendar-nav-icon calendar-nav-icon-next' }) ), h( 'button', { type: 'button', class: 'calendar-nav-btn', 'aria-label': props.ariaNavNextYearLabel, onClick: () => handleNavigationOnClick('next', true), }, /** * @slot Location for double next icon. */ slots.navNextDoubleIcon ? slots.navNextDoubleIcon() : h('span', { class: 'calendar-nav-icon calendar-nav-icon-double-next' }) ), ] ), ]) } return () => h( 'div', { class: [ 'calendars', { [`select-${props.selectionType}`]: props.selectionType && view.value === 'days', 'show-week-numbers': props.showWeekNumber, }, ], ref: calendarRef, }, [ Array.from({ length: props.calendars }, (_, index) => { const _calendarDate = getCalendarDate(calendarDate.value as Date, index, view.value) return h('div', { class: ['calendar', view.value] }, [ Navigation(_calendarDate), Calendar(_calendarDate), ]) }), ] ) }, }) export { CCalendar }