fix(datetime): prevent navigating to disabled months (#24421)

Resolves #24208, #24482
This commit is contained in:
Sean Perkins
2022-02-01 12:57:03 -05:00
committed by GitHub
parent 6d4a07d05c
commit b40fc4632e
6 changed files with 225 additions and 17 deletions

View File

@ -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
*/

View File

@ -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
});
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();
}}
></ion-picker-column-internal>
@ -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();
}}
></ion-picker-column-internal>
@ -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">
@ -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>
@ -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;
@ -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,

View File

@ -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');
});

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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
});
}