Skip to content

Commit

Permalink
fix(datetime): prevent navigating to disabled months (#24421)
Browse files Browse the repository at this point in the history
Resolves #24208, #24482
  • Loading branch information
sean-perkins authored Feb 1, 2022
1 parent 6d4a07d commit b40fc46
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 17 deletions.
7 changes: 7 additions & 0 deletions core/src/components/datetime/datetime.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
95 changes: 81 additions & 14 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ import {
} from './utils/parse';
import {
getCalendarDayState,
isDayDisabled
isDayDisabled,
isMonthDisabled,
isNextMonthDisabled,
isPrevMonthDisabled
} from './utils/state';

/**
Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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();
}}
></ion-picker-column-internal>
Expand All @@ -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
Expand All @@ -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();
}}
></ion-picker-column-internal>
Expand All @@ -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 (
<div class="calendar-header">
<div class="calendar-action-buttons">
Expand All @@ -1152,10 +1198,14 @@ export class Datetime implements ComponentInterface {

<div class="calendar-next-prev">
<ion-buttons>
<ion-button onClick={() => this.prevMonth()}>
<ion-button
disabled={prevMonthDisabled}
onClick={() => this.prevMonth()}>
<ion-icon slot="icon-only" icon={chevronBack} lazy={false} flipRtl></ion-icon>
</ion-button>
<ion-button onClick={() => this.nextMonth()}>
<ion-button
disabled={nextMonthDisabled}
onClick={() => this.nextMonth()}>
<ion-icon slot="icon-only" icon={chevronForward} lazy={false} flipRtl></ion-icon>
</ion-button>
</ion-buttons>
Expand All @@ -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 (
<div class="calendar-month">
<div class={{
'calendar-month': true,
// Prevents scroll snap swipe gestures for months outside of the min/max bounds
'calendar-month-disabled': !isWorkingMonth && swipeDisabled
}}>
<div class="calendar-month-grid">
{getDaysOfMonth(month, year, this.firstDayOfWeek % 7).map((dateObject, index) => {
const { day, dayOfWeek } = dateObject;
Expand All @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion core/src/components/datetime/test/minmax/e2e.ts
Original file line number Diff line number Diff line change
@@ -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'
});
Expand All @@ -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');

});
2 changes: 1 addition & 1 deletion core/src/components/datetime/test/minmax/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<div class="grid">
<div class="grid-item">
<h2>Value inside Bounds</h2>
<ion-datetime id="inside" min="2021-09" max="2021-10"></ion-datetime>
<ion-datetime id="inside" min="2021-09" max="2021-10" value="2021-10-01"></ion-datetime>
</div>
<div class="grid-item">
<h2>Value Outside Bounds</h2>
Expand Down
59 changes: 58 additions & 1 deletion core/src/components/datetime/test/state.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
getCalendarDayState,
isDayDisabled
isDayDisabled,
isNextMonthDisabled,
isPrevMonthDisabled
} from '../utils/state';

describe('getCalendarDayState()', () => {
Expand Down Expand Up @@ -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);
});

});

Loading

0 comments on commit b40fc46

Please sign in to comment.