Skip to content

Commit b40fc46

Browse files
authored
fix(datetime): prevent navigating to disabled months (#24421)
Resolves #24208, #24482
1 parent 6d4a07d commit b40fc46

File tree

6 files changed

+225
-17
lines changed

6 files changed

+225
-17
lines changed

core/src/components/datetime/datetime.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,13 @@
241241
width: 100%;
242242
}
243243

244+
:host .calendar-body .calendar-month-disabled {
245+
/**
246+
* Disables swipe gesture snapping for scroll-snap containers
247+
*/
248+
scroll-snap-align: none;
249+
}
250+
244251
/**
245252
* Hide scrollbars on Chrome and Safari
246253
*/

core/src/components/datetime/datetime.tsx

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ import {
5656
} from './utils/parse';
5757
import {
5858
getCalendarDayState,
59-
isDayDisabled
59+
isDayDisabled,
60+
isMonthDisabled,
61+
isNextMonthDisabled,
62+
isPrevMonthDisabled
6063
} from './utils/state';
6164

6265
/**
@@ -714,6 +717,15 @@ export class Datetime implements ComponentInterface {
714717
return;
715718
}
716719

720+
const { month, year, day } = refMonthFn(this.workingParts);
721+
722+
if (isMonthDisabled({ month, year, day: null }, {
723+
minParts: this.minParts,
724+
maxParts: this.maxParts
725+
})) {
726+
return;
727+
}
728+
717729
/**
718730
* On iOS, we need to set pointer-events: none
719731
* when the user is almost done with the gesture
@@ -724,7 +736,8 @@ export class Datetime implements ComponentInterface {
724736
*/
725737
if (mode === 'ios') {
726738
const ratio = ev.intersectionRatio;
727-
const shouldDisable = Math.abs(ratio - 0.7) <= 0.1;
739+
// `maxTouchPoints` will be 1 in device preview, but > 1 on device
740+
const shouldDisable = Math.abs(ratio - 0.7) <= 0.1 && navigator.maxTouchPoints > 1;
728741

729742
if (shouldDisable) {
730743
calendarBodyRef.style.setProperty('pointer-events', 'none');
@@ -757,7 +770,6 @@ export class Datetime implements ComponentInterface {
757770
* if we did not do this.
758771
*/
759772
writeTask(() => {
760-
const { month, year, day } = refMonthFn(this.workingParts);
761773

762774
this.setWorkingParts({
763775
...this.workingParts,
@@ -766,9 +778,11 @@ export class Datetime implements ComponentInterface {
766778
year
767779
});
768780

769-
calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1);
770-
calendarBodyRef.style.removeProperty('overflow');
771-
calendarBodyRef.style.removeProperty('pointer-events');
781+
raf(() => {
782+
calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1);
783+
calendarBodyRef.style.removeProperty('overflow');
784+
calendarBodyRef.style.removeProperty('pointer-events');
785+
});
772786

773787
/**
774788
* Now that state has been updated
@@ -781,6 +795,12 @@ export class Datetime implements ComponentInterface {
781795
});
782796
}
783797

798+
const threshold = mode === 'ios' &&
799+
// tslint:disable-next-line
800+
typeof navigator !== 'undefined' &&
801+
navigator.maxTouchPoints > 1 ?
802+
[0.7, 1] : 1;
803+
784804
/**
785805
* Listen on the first month to
786806
* prepend a new month and on the last
@@ -800,13 +820,13 @@ export class Datetime implements ComponentInterface {
800820
* something WebKit does.
801821
*/
802822
endIO = new IntersectionObserver(ev => ioCallback('end', ev), {
803-
threshold: mode === 'ios' ? [0.7, 1] : 1,
823+
threshold,
804824
root: calendarBodyRef
805825
});
806826
endIO.observe(endMonth);
807827

808828
startIO = new IntersectionObserver(ev => ioCallback('start', ev), {
809-
threshold: mode === 'ios' ? [0.7, 1] : 1,
829+
threshold,
810830
root: calendarBodyRef
811831
});
812832
startIO.observe(startMonth);
@@ -963,9 +983,9 @@ export class Datetime implements ComponentInterface {
963983
}
964984

965985
componentWillLoad() {
966-
this.processValue(this.value);
967986
this.processMinParts();
968987
this.processMaxParts();
988+
this.processValue(this.value);
969989
this.parsedHourValues = convertToArrayOfNumbers(this.hourValues);
970990
this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues);
971991
this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues);
@@ -1091,6 +1111,13 @@ export class Datetime implements ComponentInterface {
10911111
items={months}
10921112
value={workingParts.month}
10931113
onIonChange={(ev: CustomEvent) => {
1114+
// Due to a Safari 14 issue we need to destroy
1115+
// the intersection observer before we update state
1116+
// and trigger a re-render.
1117+
if (this.destroyCalendarIO) {
1118+
this.destroyCalendarIO();
1119+
}
1120+
10941121
this.setWorkingParts({
10951122
...this.workingParts,
10961123
month: ev.detail.value
@@ -1103,6 +1130,10 @@ export class Datetime implements ComponentInterface {
11031130
});
11041131
}
11051132

1133+
// We can re-attach the intersection observer after
1134+
// the working parts have been updated.
1135+
this.initializeCalendarIOListeners();
1136+
11061137
ev.stopPropagation();
11071138
}}
11081139
></ion-picker-column-internal>
@@ -1114,6 +1145,13 @@ export class Datetime implements ComponentInterface {
11141145
items={years}
11151146
value={workingParts.year}
11161147
onIonChange={(ev: CustomEvent) => {
1148+
// Due to a Safari 14 issue we need to destroy
1149+
// the intersection observer before we update state
1150+
// and trigger a re-render.
1151+
if (this.destroyCalendarIO) {
1152+
this.destroyCalendarIO();
1153+
}
1154+
11171155
this.setWorkingParts({
11181156
...this.workingParts,
11191157
year: ev.detail.value
@@ -1126,6 +1164,10 @@ export class Datetime implements ComponentInterface {
11261164
});
11271165
}
11281166

1167+
// We can re-attach the intersection observer after
1168+
// the working parts have been updated.
1169+
this.initializeCalendarIOListeners();
1170+
11291171
ev.stopPropagation();
11301172
}}
11311173
></ion-picker-column-internal>
@@ -1139,6 +1181,10 @@ export class Datetime implements ComponentInterface {
11391181
private renderCalendarHeader(mode: Mode) {
11401182
const expandedIcon = mode === 'ios' ? chevronDown : caretUpSharp;
11411183
const collapsedIcon = mode === 'ios' ? chevronForward : caretDownSharp;
1184+
1185+
const prevMonthDisabled = isPrevMonthDisabled(this.workingParts, this.minParts, this.maxParts);
1186+
const nextMonthDisabled = isNextMonthDisabled(this.workingParts, this.maxParts);
1187+
11421188
return (
11431189
<div class="calendar-header">
11441190
<div class="calendar-action-buttons">
@@ -1152,10 +1198,14 @@ export class Datetime implements ComponentInterface {
11521198

11531199
<div class="calendar-next-prev">
11541200
<ion-buttons>
1155-
<ion-button onClick={() => this.prevMonth()}>
1201+
<ion-button
1202+
disabled={prevMonthDisabled}
1203+
onClick={() => this.prevMonth()}>
11561204
<ion-icon slot="icon-only" icon={chevronBack} lazy={false} flipRtl></ion-icon>
11571205
</ion-button>
1158-
<ion-button onClick={() => this.nextMonth()}>
1206+
<ion-button
1207+
disabled={nextMonthDisabled}
1208+
onClick={() => this.nextMonth()}>
11591209
<ion-icon slot="icon-only" icon={chevronForward} lazy={false} flipRtl></ion-icon>
11601210
</ion-button>
11611211
</ion-buttons>
@@ -1173,9 +1223,26 @@ export class Datetime implements ComponentInterface {
11731223
private renderMonth(month: number, year: number) {
11741224
const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year);
11751225
const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month);
1176-
const isMonthDisabled = !yearAllowed || !monthAllowed;
1226+
const isCalMonthDisabled = !yearAllowed || !monthAllowed;
1227+
const swipeDisabled = isMonthDisabled({
1228+
month,
1229+
year,
1230+
day: null
1231+
}, {
1232+
minParts: this.minParts,
1233+
maxParts: this.maxParts
1234+
});
1235+
// The working month should never have swipe disabled.
1236+
// Otherwise the CSS scroll snap will not work and the user
1237+
// can free-scroll the calendar.
1238+
const isWorkingMonth = this.workingParts.month === month && this.workingParts.year === year;
1239+
11771240
return (
1178-
<div class="calendar-month">
1241+
<div class={{
1242+
'calendar-month': true,
1243+
// Prevents scroll snap swipe gestures for months outside of the min/max bounds
1244+
'calendar-month-disabled': !isWorkingMonth && swipeDisabled
1245+
}}>
11791246
<div class="calendar-month-grid">
11801247
{getDaysOfMonth(month, year, this.firstDayOfWeek % 7).map((dateObject, index) => {
11811248
const { day, dayOfWeek } = dateObject;
@@ -1190,7 +1257,7 @@ export class Datetime implements ComponentInterface {
11901257
data-year={year}
11911258
data-index={index}
11921259
data-day-of-week={dayOfWeek}
1193-
disabled={isMonthDisabled || disabled}
1260+
disabled={isCalMonthDisabled || disabled}
11941261
class={{
11951262
'calendar-day-padding': day === null,
11961263
'calendar-day': true,

core/src/components/datetime/test/minmax/e2e.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { newE2EPage } from '@stencil/core/testing';
22

3-
test('minmax', async () => {
3+
test('datetime: minmax', async () => {
44
const page = await newE2EPage({
55
url: '/src/components/datetime/test/minmax?ionic:_testing=true'
66
});
@@ -20,3 +20,30 @@ test('minmax', async () => {
2020
expect(screenshotCompare).toMatchScreenshot();
2121
}
2222
});
23+
24+
test('datetime: minmax months disabled', async () => {
25+
const page = await newE2EPage({
26+
url: '/src/components/datetime/test/minmax?ionic:_testing=true'
27+
});
28+
29+
const calendarMonths = await page.findAll('ion-datetime#inside >>> .calendar-month');
30+
31+
await page.waitForChanges();
32+
33+
expect(calendarMonths[0]).not.toHaveClass('calendar-month-disabled');
34+
expect(calendarMonths[1]).not.toHaveClass('calendar-month-disabled');
35+
expect(calendarMonths[2]).toHaveClass('calendar-month-disabled');
36+
37+
});
38+
39+
test('datetime: minmax navigation disabled', async () => {
40+
const page = await newE2EPage({
41+
url: '/src/components/datetime/test/minmax?ionic:_testing=true'
42+
});
43+
44+
const navButtons = await page.findAll('ion-datetime#outside >>> .calendar-next-prev ion-button');
45+
46+
expect(navButtons[0]).toHaveAttribute('disabled');
47+
expect(navButtons[1]).toHaveAttribute('disabled');
48+
49+
});

core/src/components/datetime/test/minmax/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
<div class="grid">
4545
<div class="grid-item">
4646
<h2>Value inside Bounds</h2>
47-
<ion-datetime id="inside" min="2021-09" max="2021-10"></ion-datetime>
47+
<ion-datetime id="inside" min="2021-09" max="2021-10" value="2021-10-01"></ion-datetime>
4848
</div>
4949
<div class="grid-item">
5050
<h2>Value Outside Bounds</h2>

core/src/components/datetime/test/state.spec.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
getCalendarDayState,
3-
isDayDisabled
3+
isDayDisabled,
4+
isNextMonthDisabled,
5+
isPrevMonthDisabled
46
} from '../utils/state';
57

68
describe('getCalendarDayState()', () => {
@@ -73,3 +75,58 @@ describe('isDayDisabled()', () => {
7375
expect(isDayDisabled(refDate, undefined, { month: 5, day: 11, year: 2021 })).toEqual(true);
7476
})
7577
});
78+
79+
describe('isPrevMonthDisabled()', () => {
80+
81+
it('should return true', () => {
82+
// Date month is before min month, in the same year
83+
expect(isPrevMonthDisabled({ month: 5, year: 2021, day: null }, { month: 6, year: 2021, day: null })).toEqual(true);
84+
// Date month and year is the same as min month and year
85+
expect(isPrevMonthDisabled({ month: 1, year: 2021, day: null }, { month: 1, year: 2021, day: null })).toEqual(true);
86+
// Date year is the same as min year (month not provided)
87+
expect(isPrevMonthDisabled({ month: 1, year: 2021, day: null }, { year: 2021, month: null, day: null })).toEqual(true);
88+
// Date year is less than the min year (month not provided)
89+
expect(isPrevMonthDisabled({ month: 5, year: 2021, day: null }, { year: 2022, month: null, day: null })).toEqual(true);
90+
91+
// Date is above the maximum bounds and the previous month does not does not fall within the
92+
// min-max range.
93+
expect(isPrevMonthDisabled({ month: 12, year: 2021, day: null }, { month: 9, year: 2021, day: null }, { month: 10, year: 2021, day: null })).toEqual(true);
94+
95+
// Date is above the maximum bounds and a year ahead of the max range. The previous month/year
96+
// does not fall within the min-max range.
97+
expect(isPrevMonthDisabled({ month: 1, year: 2022, day: null }, { month: 9, year: 2021, day: null }, { month: 10, year: 2021, day: null })).toEqual(true);
98+
99+
});
100+
101+
it('should return false', () => {
102+
// No min range provided
103+
expect(isPrevMonthDisabled({ month: 12, year: 2021, day: null })).toEqual(false);
104+
// Date year is the same as min year,
105+
// but can navigate to a previous month without reducing the year.
106+
expect(isPrevMonthDisabled({ month: 12, year: 2021, day: null }, { year: 2021, month: null, day: null })).toEqual(false);
107+
expect(isPrevMonthDisabled({ month: 2, year: 2021, day: null }, { year: 2021, month: null, day: null })).toEqual(false);
108+
});
109+
110+
});
111+
112+
describe('isNextMonthDisabled()', () => {
113+
114+
it('should return true', () => {
115+
// Date month is the same as max month (in the same year)
116+
expect(isNextMonthDisabled({ month: 10, year: 2021, day: null }, { month: 10, year: 2021, day: null })).toEqual(true);
117+
// Date month is after the max month (in the same year)
118+
expect(isNextMonthDisabled({ month: 10, year: 2021, day: null }, { month: 9, year: 2021, day: null })).toEqual(true);
119+
// Date year is after the max month and year
120+
expect(isNextMonthDisabled({ month: 10, year: 2022, day: null }, { month: 12, year: 2021, day: null })).toEqual(true);
121+
});
122+
123+
it('should return false', () => {
124+
// No max range provided
125+
expect(isNextMonthDisabled({ month: 10, year: 2021, day: null })).toBe(false);
126+
// Date month is before max month and is the previous month,
127+
// so that navigating the next month would re-enter the max range
128+
expect(isNextMonthDisabled({ month: 10, year: 2021, day: null }, { month: 11, year: 2021, day: null })).toEqual(false);
129+
});
130+
131+
});
132+

0 commit comments

Comments
 (0)