From f809f79fe0cbf0dd6b95184d75c139af880f16ee Mon Sep 17 00:00:00 2001 From: Jasenko Karovic Date: Sat, 4 Jan 2025 12:29:26 +0100 Subject: [PATCH] feat: Mark dates as disabled after range start if min or max range config is provided (resolves #1039) --- index.d.ts | 4 +- .../composables/calendar-class.ts | 46 +++++++++++++++++-- src/VueDatePicker/interfaces.ts | 2 +- src/VueDatePicker/utils/date-utils.ts | 8 ++++ tests/unit/behaviour.spec.ts | 31 +++++++++---- tests/utils.ts | 15 +++++- 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/index.d.ts b/index.d.ts index ec2ae89d..a43ee405 100644 --- a/index.d.ts +++ b/index.d.ts @@ -131,7 +131,7 @@ export interface GeneralConfig { closeOnAutoApply?: boolean; noSwipe?: boolean; keepActionRow?: boolean; - onClickOutside?: (validate: () => boolean) => void; + onClickOutside?: (validate: () => boolean, evt: PointerEvent) => void; tabOutClosesMenu?: boolean; arrowLeft?: string; keepViewOnOffsetClick?: boolean; @@ -300,7 +300,7 @@ export interface VueDatePickerProps { disableYearSelect?: boolean; focusStartDate?: boolean; disabledTimes?: - | ((time: TimeObj | TimeObj[] | (TimeObj | undefined)[]) => boolean) + | ((time: TimeObj | (TimeObj | undefined)[]) => boolean) | DisabledTime[] | [DisabledTime[], DisabledTime[]]; timePickerInline?: boolean; diff --git a/src/VueDatePicker/composables/calendar-class.ts b/src/VueDatePicker/composables/calendar-class.ts index 001c7da6..e803627f 100644 --- a/src/VueDatePicker/composables/calendar-class.ts +++ b/src/VueDatePicker/composables/calendar-class.ts @@ -1,9 +1,17 @@ import { ref } from 'vue'; -import { addDays } from 'date-fns'; +import { addDays, isAfter, isBefore } from 'date-fns'; import { useDefaults, useValidation } from '@/composables/index'; import { isModelAuto, matchDate } from '@/utils/util'; -import { isDateAfter, isDateBefore, isDateBetween, isDateEqual, getDate, getWeekFromDate } from '@/utils/date-utils'; +import { + isDateAfter, + isDateBefore, + isDateBetween, + isDateEqual, + getDate, + getWeekFromDate, + getBeforeAndAfterInRange, +} from '@/utils/date-utils'; import { localToTz } from '@/utils/timezone'; import type { UnwrapRef, WritableComputedRef } from 'vue'; @@ -244,14 +252,44 @@ export const useCalendarClass = (modelValue: WritableComputedRef { + if (Array.isArray(modelValue.value) && modelValue.value.length === 1) { + const { before, after } = getBeforeAndAfterInRange(+defaultedRange.value.maxRange!, modelValue.value[0]); + return isBefore(day.value, before) || isAfter(day.value, after); + } + return false; + }; + + const isDateBeforeMinRange = (day: ICalendarDay) => { + if (Array.isArray(modelValue.value) && modelValue.value.length === 1) { + const { before, after } = getBeforeAndAfterInRange(+defaultedRange.value.minRange!, modelValue.value[0]); + return isDateBetween([before, after], modelValue.value[0], day.value); + } + return false; + }; + + const minMaxRangeDate = (day: ICalendarDay) => { + if (defaultedRange.value.enabled && (defaultedRange.value.maxRange || defaultedRange.value.minRange)) { + if (defaultedRange.value.maxRange && defaultedRange.value.minRange) { + return isDateAfterMaxRange(day) || isDateBeforeMinRange(day); + } + return defaultedRange.value.maxRange ? isDateAfterMaxRange(day) : isDateBeforeMinRange(day); + } + return false; + }; + // Common classes to be checked for any mode const sharedClasses = (day: ICalendarDay): Record => { const { isRangeStart, isRangeEnd } = rangeStartEnd(day); const isRangeStartEnd = defaultedRange.value.enabled ? isRangeStart || isRangeEnd : false; return { dp__cell_offset: !day.current, - dp__pointer: !props.disabled && !(!day.current && props.hideOffsetDates) && !isDisabled(day.value), - dp__cell_disabled: isDisabled(day.value), + dp__pointer: + !props.disabled && + !(!day.current && props.hideOffsetDates) && + !isDisabled(day.value) && + !minMaxRangeDate(day), + dp__cell_disabled: isDisabled(day.value) || minMaxRangeDate(day), dp__cell_highlight: !disableHighlight(day) && (highlighted(day) || highlightedWeekDay(day)) && diff --git a/src/VueDatePicker/interfaces.ts b/src/VueDatePicker/interfaces.ts index 5f1afec2..da041430 100644 --- a/src/VueDatePicker/interfaces.ts +++ b/src/VueDatePicker/interfaces.ts @@ -282,7 +282,7 @@ export interface Config { closeOnAutoApply: boolean; noSwipe: boolean; keepActionRow: boolean; - onClickOutside?: (validate: () => boolean) => void; + onClickOutside?: (validate: () => boolean, evt: PointerEvent) => void; tabOutClosesMenu: boolean; arrowLeft?: string; keepViewOnOffsetClick?: boolean; diff --git a/src/VueDatePicker/utils/date-utils.ts b/src/VueDatePicker/utils/date-utils.ts index 2c64ff44..abfda50a 100644 --- a/src/VueDatePicker/utils/date-utils.ts +++ b/src/VueDatePicker/utils/date-utils.ts @@ -24,6 +24,8 @@ import { subMonths, format, startOfMonth, + subDays, + addDays, } from 'date-fns'; import { errors } from '@/utils/util'; @@ -443,3 +445,9 @@ export const checkHighlightYear = (defaultedHighlight: Highlight | HighlightFn, export const getCellId = (date: Date) => { return format(date, 'yyyy-MM-dd'); }; + +export const getBeforeAndAfterInRange = (range: number, date: Date) => { + const before = subDays(resetDateTime(date), range); + const after = addDays(resetDateTime(date), range); + return { before, after }; +}; diff --git a/tests/unit/behaviour.spec.ts b/tests/unit/behaviour.spec.ts index 883780c6..a451a901 100644 --- a/tests/unit/behaviour.spec.ts +++ b/tests/unit/behaviour.spec.ts @@ -16,6 +16,7 @@ import { startOfMonth, startOfQuarter, startOfYear, + setDate, } from 'date-fns'; import { resetDateTime } from '@/utils/date-utils'; @@ -23,6 +24,7 @@ import { resetDateTime } from '@/utils/date-utils'; import { clickCalendarDate, clickSelectBtn, + getCellClasses, getMonthName, hoverCalendarDate, openMenu, @@ -153,19 +155,12 @@ describe('It should validate various picker scenarios', () => { const disabledDates = [addDays(today, 1)]; const dp = await openMenu({ disabledDates }); - const getCellClasses = (date: Date) => { - const el = dp.find(`[data-test-id="${date}"]`); - const innerCell = el.find('.dp__cell_inner'); - - return innerCell.classes(); - }; - - expect(getCellClasses(resetDateTime(disabledDates[0]))).toContain('dp__cell_disabled'); + expect(getCellClasses(dp, disabledDates[0])).toContain('dp__cell_disabled'); const updatedDisabledDates = [...disabledDates, addDays(today, 2)]; await dp.setProps({ disabledDates: updatedDisabledDates }); - expect(getCellClasses(resetDateTime(updatedDisabledDates[1]))).toContain('dp__cell_disabled'); + expect(getCellClasses(dp, updatedDisabledDates[1])).toContain('dp__cell_disabled'); dp.unmount(); }); @@ -527,6 +522,7 @@ describe('It should validate various picker scenarios', () => { await dp.find('[data-test-id="dp-input"]').trigger('click'); const menuShown = dp.find('[role="dialog"]'); expect(menuShown.exists()).toBeTruthy(); + dp.unmount(); }); it('Should trigger @text-input event when typing date #909', async () => { @@ -537,5 +533,22 @@ describe('It should validate various picker scenarios', () => { await dp.find('[data-test-id="dp-input"]').setValue('02'); expect(dp.emitted()).toHaveProperty('text-input'); expect(dp.emitted()['text-input']).toHaveLength(2); + dp.unmount(); + }); + + it('Should disable invalid dates when min and max range options are provided', async () => { + const disabledClass = 'dp__cell_disabled'; + const dp = await openMenu({ range: { minRange: 3, maxRange: 10 } }); + const start = setDate(new Date(), 15); + await clickCalendarDate(dp, start); + const disabledBeforeMin = addDays(start, 2); + const disabledAfterMax = addDays(start, 11); + const inRange = addDays(start, 7); + + expect(getCellClasses(dp, disabledBeforeMin)).toContain(disabledClass); + expect(getCellClasses(dp, disabledAfterMax)).toContain(disabledClass); + const empty = getCellClasses(dp, inRange).find((className) => className === disabledClass); + expect(empty).toBeFalsy(); + dp.unmount(); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 6b951eb7..e100977f 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -33,11 +33,11 @@ export const getMonthName = (date: Date) => { }; export const clickCalendarDate = async (dp: VueWrapper, date: Date) => { - await dp.find(`[data-test-id="${resetDateTime(date)}"]`).trigger('click'); + await getCalendarCell(dp, date).trigger('click'); }; export const hoverCalendarDate = async (dp: VueWrapper, date: Date) => { - await dp.find(`[data-test-id="${resetDateTime(date)}"]`).trigger('mouseenter'); + await getCalendarCell(dp, date).trigger('mouseenter'); }; export const clickSelectBtn = async (dp: VueWrapper) => { @@ -45,3 +45,14 @@ export const clickSelectBtn = async (dp: VueWrapper) => { }; export const padZero = (val: number) => (val < 10 ? `0${val}` : val); + +export const getCalendarCell = (dp: VueWrapper, date: Date) => { + return dp.find(`[data-test-id="${resetDateTime(date)}"]`); +}; + +export const getCellClasses = (dp: VueWrapper, date: Date) => { + const el = getCalendarCell(dp, date); + const innerCell = el.find('.dp__cell_inner'); + + return innerCell.classes(); +};