diff --git a/core/src/components/datetime/datetime.scss b/core/src/components/datetime/datetime.scss index c9af0f8024..ce8438b6db 100644 --- a/core/src/components/datetime/datetime.scss +++ b/core/src/components/datetime/datetime.scss @@ -241,6 +241,13 @@ width: 100%; } +:host .calendar-body .calendar-month-disabled { + /** + * Disables swipe gesture snapping for scroll-snap containers + */ + scroll-snap-align: none; +} + /** * Hide scrollbars on Chrome and Safari */ diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 8d63783d16..0ddfbe3eb4 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -56,7 +56,10 @@ import { } from './utils/parse'; import { getCalendarDayState, - isDayDisabled + isDayDisabled, + isMonthDisabled, + isNextMonthDisabled, + isPrevMonthDisabled } from './utils/state'; /** @@ -714,6 +717,15 @@ export class Datetime implements ComponentInterface { return; } + const { month, year, day } = refMonthFn(this.workingParts); + + if (isMonthDisabled({ month, year, day: null }, { + minParts: this.minParts, + maxParts: this.maxParts + })) { + return; + } + /** * On iOS, we need to set pointer-events: none * when the user is almost done with the gesture @@ -724,7 +736,8 @@ export class Datetime implements ComponentInterface { */ if (mode === 'ios') { const ratio = ev.intersectionRatio; - const shouldDisable = Math.abs(ratio - 0.7) <= 0.1; + // `maxTouchPoints` will be 1 in device preview, but > 1 on device + const shouldDisable = Math.abs(ratio - 0.7) <= 0.1 && navigator.maxTouchPoints > 1; if (shouldDisable) { calendarBodyRef.style.setProperty('pointer-events', 'none'); @@ -757,7 +770,6 @@ export class Datetime implements ComponentInterface { * if we did not do this. */ writeTask(() => { - const { month, year, day } = refMonthFn(this.workingParts); this.setWorkingParts({ ...this.workingParts, @@ -766,9 +778,11 @@ export class Datetime implements ComponentInterface { year }); - calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1); - calendarBodyRef.style.removeProperty('overflow'); - calendarBodyRef.style.removeProperty('pointer-events'); + raf(() => { + calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1); + calendarBodyRef.style.removeProperty('overflow'); + calendarBodyRef.style.removeProperty('pointer-events'); + }); /** * Now that state has been updated @@ -781,6 +795,12 @@ export class Datetime implements ComponentInterface { }); } + const threshold = mode === 'ios' && + // tslint:disable-next-line + typeof navigator !== 'undefined' && + navigator.maxTouchPoints > 1 ? + [0.7, 1] : 1; + /** * Listen on the first month to * prepend a new month and on the last @@ -800,13 +820,13 @@ export class Datetime implements ComponentInterface { * something WebKit does. */ endIO = new IntersectionObserver(ev => ioCallback('end', ev), { - threshold: mode === 'ios' ? [0.7, 1] : 1, + threshold, root: calendarBodyRef }); endIO.observe(endMonth); startIO = new IntersectionObserver(ev => ioCallback('start', ev), { - threshold: mode === 'ios' ? [0.7, 1] : 1, + threshold, root: calendarBodyRef }); startIO.observe(startMonth); @@ -963,9 +983,9 @@ export class Datetime implements ComponentInterface { } componentWillLoad() { - this.processValue(this.value); this.processMinParts(); this.processMaxParts(); + this.processValue(this.value); this.parsedHourValues = convertToArrayOfNumbers(this.hourValues); this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues); this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues); @@ -1091,6 +1111,13 @@ export class Datetime implements ComponentInterface { items={months} value={workingParts.month} onIonChange={(ev: CustomEvent) => { + // Due to a Safari 14 issue we need to destroy + // the intersection observer before we update state + // and trigger a re-render. + if (this.destroyCalendarIO) { + this.destroyCalendarIO(); + } + this.setWorkingParts({ ...this.workingParts, month: ev.detail.value @@ -1103,6 +1130,10 @@ export class Datetime implements ComponentInterface { }); } + // We can re-attach the intersection observer after + // the working parts have been updated. + this.initializeCalendarIOListeners(); + ev.stopPropagation(); }} > @@ -1114,6 +1145,13 @@ export class Datetime implements ComponentInterface { items={years} value={workingParts.year} onIonChange={(ev: CustomEvent) => { + // Due to a Safari 14 issue we need to destroy + // the intersection observer before we update state + // and trigger a re-render. + if (this.destroyCalendarIO) { + this.destroyCalendarIO(); + } + this.setWorkingParts({ ...this.workingParts, year: ev.detail.value @@ -1126,6 +1164,10 @@ export class Datetime implements ComponentInterface { }); } + // We can re-attach the intersection observer after + // the working parts have been updated. + this.initializeCalendarIOListeners(); + ev.stopPropagation(); }} > @@ -1139,6 +1181,10 @@ export class Datetime implements ComponentInterface { private renderCalendarHeader(mode: Mode) { const expandedIcon = mode === 'ios' ? chevronDown : caretUpSharp; const collapsedIcon = mode === 'ios' ? chevronForward : caretDownSharp; + + const prevMonthDisabled = isPrevMonthDisabled(this.workingParts, this.minParts, this.maxParts); + const nextMonthDisabled = isNextMonthDisabled(this.workingParts, this.maxParts); + return (
@@ -1152,10 +1198,14 @@ export class Datetime implements ComponentInterface {
- this.prevMonth()}> + this.prevMonth()}> - this.nextMonth()}> + this.nextMonth()}> @@ -1173,9 +1223,26 @@ export class Datetime implements ComponentInterface { private renderMonth(month: number, year: number) { const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year); const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month); - const isMonthDisabled = !yearAllowed || !monthAllowed; + const isCalMonthDisabled = !yearAllowed || !monthAllowed; + const swipeDisabled = isMonthDisabled({ + month, + year, + day: null + }, { + minParts: this.minParts, + maxParts: this.maxParts + }); + // The working month should never have swipe disabled. + // Otherwise the CSS scroll snap will not work and the user + // can free-scroll the calendar. + const isWorkingMonth = this.workingParts.month === month && this.workingParts.year === year; + return ( -
+
{getDaysOfMonth(month, year, this.firstDayOfWeek % 7).map((dateObject, index) => { const { day, dayOfWeek } = dateObject; @@ -1190,7 +1257,7 @@ export class Datetime implements ComponentInterface { data-year={year} data-index={index} data-day-of-week={dayOfWeek} - disabled={isMonthDisabled || disabled} + disabled={isCalMonthDisabled || disabled} class={{ 'calendar-day-padding': day === null, 'calendar-day': true, diff --git a/core/src/components/datetime/test/minmax/e2e.ts b/core/src/components/datetime/test/minmax/e2e.ts index d37ff37761..f040b65d66 100644 --- a/core/src/components/datetime/test/minmax/e2e.ts +++ b/core/src/components/datetime/test/minmax/e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from '@stencil/core/testing'; -test('minmax', async () => { +test('datetime: minmax', async () => { const page = await newE2EPage({ url: '/src/components/datetime/test/minmax?ionic:_testing=true' }); @@ -20,3 +20,30 @@ test('minmax', async () => { expect(screenshotCompare).toMatchScreenshot(); } }); + +test('datetime: minmax months disabled', async () => { + const page = await newE2EPage({ + url: '/src/components/datetime/test/minmax?ionic:_testing=true' + }); + + const calendarMonths = await page.findAll('ion-datetime#inside >>> .calendar-month'); + + await page.waitForChanges(); + + expect(calendarMonths[0]).not.toHaveClass('calendar-month-disabled'); + expect(calendarMonths[1]).not.toHaveClass('calendar-month-disabled'); + expect(calendarMonths[2]).toHaveClass('calendar-month-disabled'); + +}); + +test('datetime: minmax navigation disabled', async () => { + const page = await newE2EPage({ + url: '/src/components/datetime/test/minmax?ionic:_testing=true' + }); + + const navButtons = await page.findAll('ion-datetime#outside >>> .calendar-next-prev ion-button'); + + expect(navButtons[0]).toHaveAttribute('disabled'); + expect(navButtons[1]).toHaveAttribute('disabled'); + +}); diff --git a/core/src/components/datetime/test/minmax/index.html b/core/src/components/datetime/test/minmax/index.html index fbba64a03a..1f66778de1 100644 --- a/core/src/components/datetime/test/minmax/index.html +++ b/core/src/components/datetime/test/minmax/index.html @@ -44,7 +44,7 @@

Value inside Bounds

- +

Value Outside Bounds

diff --git a/core/src/components/datetime/test/state.spec.ts b/core/src/components/datetime/test/state.spec.ts index 1bcc0eb5d0..9d4b2af110 100644 --- a/core/src/components/datetime/test/state.spec.ts +++ b/core/src/components/datetime/test/state.spec.ts @@ -1,6 +1,8 @@ import { getCalendarDayState, - isDayDisabled + isDayDisabled, + isNextMonthDisabled, + isPrevMonthDisabled } from '../utils/state'; describe('getCalendarDayState()', () => { @@ -73,3 +75,58 @@ describe('isDayDisabled()', () => { expect(isDayDisabled(refDate, undefined, { month: 5, day: 11, year: 2021 })).toEqual(true); }) }); + +describe('isPrevMonthDisabled()', () => { + + it('should return true', () => { + // Date month is before min month, in the same year + expect(isPrevMonthDisabled({ month: 5, year: 2021, day: null }, { month: 6, year: 2021, day: null })).toEqual(true); + // Date month and year is the same as min month and year + expect(isPrevMonthDisabled({ month: 1, year: 2021, day: null }, { month: 1, year: 2021, day: null })).toEqual(true); + // Date year is the same as min year (month not provided) + expect(isPrevMonthDisabled({ month: 1, year: 2021, day: null }, { year: 2021, month: null, day: null })).toEqual(true); + // Date year is less than the min year (month not provided) + expect(isPrevMonthDisabled({ month: 5, year: 2021, day: null }, { year: 2022, month: null, day: null })).toEqual(true); + + // Date is above the maximum bounds and the previous month does not does not fall within the + // min-max range. + expect(isPrevMonthDisabled({ month: 12, year: 2021, day: null }, { month: 9, year: 2021, day: null }, { month: 10, year: 2021, day: null })).toEqual(true); + + // Date is above the maximum bounds and a year ahead of the max range. The previous month/year + // does not fall within the min-max range. + expect(isPrevMonthDisabled({ month: 1, year: 2022, day: null }, { month: 9, year: 2021, day: null }, { month: 10, year: 2021, day: null })).toEqual(true); + + }); + + it('should return false', () => { + // No min range provided + expect(isPrevMonthDisabled({ month: 12, year: 2021, day: null })).toEqual(false); + // Date year is the same as min year, + // but can navigate to a previous month without reducing the year. + expect(isPrevMonthDisabled({ month: 12, year: 2021, day: null }, { year: 2021, month: null, day: null })).toEqual(false); + expect(isPrevMonthDisabled({ month: 2, year: 2021, day: null }, { year: 2021, month: null, day: null })).toEqual(false); + }); + +}); + +describe('isNextMonthDisabled()', () => { + + it('should return true', () => { + // Date month is the same as max month (in the same year) + expect(isNextMonthDisabled({ month: 10, year: 2021, day: null }, { month: 10, year: 2021, day: null })).toEqual(true); + // Date month is after the max month (in the same year) + expect(isNextMonthDisabled({ month: 10, year: 2021, day: null }, { month: 9, year: 2021, day: null })).toEqual(true); + // Date year is after the max month and year + expect(isNextMonthDisabled({ month: 10, year: 2022, day: null }, { month: 12, year: 2021, day: null })).toEqual(true); + }); + + it('should return false', () => { + // No max range provided + expect(isNextMonthDisabled({ month: 10, year: 2021, day: null })).toBe(false); + // Date month is before max month and is the previous month, + // so that navigating the next month would re-enter the max range + expect(isNextMonthDisabled({ month: 10, year: 2021, day: null }, { month: 11, year: 2021, day: null })).toEqual(false); + }); + +}); + diff --git a/core/src/components/datetime/utils/state.ts b/core/src/components/datetime/utils/state.ts index 4e279a9407..4c2a4f93d6 100644 --- a/core/src/components/datetime/utils/state.ts +++ b/core/src/components/datetime/utils/state.ts @@ -2,6 +2,7 @@ import { DatetimeParts } from '../datetime-interface'; import { isAfter, isBefore, isSameDay } from './comparison'; import { generateDayAriaLabel } from './format'; +import { getNextMonth, getPreviousMonth } from './manipulation'; export const isYearDisabled = (refYear: number, minParts?: DatetimeParts, maxParts?: DatetimeParts) => { if (minParts && minParts.year > refYear) { @@ -102,3 +103,52 @@ export const getCalendarDayState = ( ariaLabel: generateDayAriaLabel(locale, isToday, refParts) } } + +/** + * Returns `true` if the month is disabled given the + * current date value and min/max date constraints. + */ +export const isMonthDisabled = (refParts: DatetimeParts, { minParts, maxParts }: { + minParts?: DatetimeParts, + maxParts?: DatetimeParts +}) => { + // If the year is disabled then the month is disabled. + if (isYearDisabled(refParts.year, minParts, maxParts)) { + return true; + } + // If the date value is before the min date, then the month is disabled. + // If the date value is after the max date, then the month is disabled. + if (minParts && isBefore(refParts, minParts) || maxParts && isAfter(refParts, maxParts)) { + return true; + } + return false; +} + +/** + * Given a working date, an optional minimum date range, + * and an optional maximum date range; determine if the + * previous navigation button is disabled. + */ +export const isPrevMonthDisabled = ( + refParts: DatetimeParts, + minParts?: DatetimeParts, + maxParts?: DatetimeParts) => { + const prevMonth = getPreviousMonth(refParts); + return isMonthDisabled(prevMonth, { + minParts, + maxParts + }); +} + +/** + * Given a working date and a maximum date range, + * determine if the next navigation button is disabled. + */ +export const isNextMonthDisabled = ( + refParts: DatetimeParts, + maxParts?: DatetimeParts) => { + const nextMonth = getNextMonth(refParts); + return isMonthDisabled(nextMonth, { + maxParts + }); +}