Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if we remove this? I tried testing in iOS 14 and I did not notice anything break.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This specific code was to catch an issue when using the calendar month picker and changing months through that UI would re-render and cause the intersection observer to update the month back to the previous month.

I noticed it prior by going from October -> September, then expanding the month picker and changing between months.

Was from this comment: #24421 (comment) (only applicable in Safari < 15).

Will prioritize time later tonight/early tomorrow to address the other quick scroll issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have identified this as a webkit bug, new issue being tracked here: #24683.

PR is ready to merge in the interim.

}

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