mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-19 11:41:20 +08:00
fix(datetime): prevent navigating to disabled months (#24421)
Resolves #24208, #24482
This commit is contained in:
@ -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
|
||||
*/
|
||||
|
@ -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();
|
||||
}}
|
||||
></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,
|
||||
|
@ -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');
|
||||
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user