diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index 46aa2a3b077..43467911c83 100644 --- a/core/src/components/datetime-button/datetime-button.tsx +++ b/core/src/components/datetime-button/datetime-button.tsx @@ -206,6 +206,10 @@ export class DatetimeButton implements ComponentInterface { */ const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]); + if (!parsedDatetimes) { + return; + } + /** * If developers incorrectly use multiple="true" * with non "date" datetimes, then just select diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index b2f4e30d785..2a4a6cb8c9e 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -117,11 +117,7 @@ export class Datetime implements ComponentInterface { private prevPresentation: string | null = null; - /** - * Duplicate reference to `activeParts` that does not trigger a re-render of the component. - * Allows caching an instance of the `activeParts` in between render cycles. - */ - private activePartsClone: DatetimeParts | DatetimeParts[] = []; + private resolveForceDateScrolling?: () => void; @State() showMonthAndYear = false; @@ -140,6 +136,17 @@ export class Datetime implements ComponentInterface { @State() isTimePopoverOpen = false; + /** + * When defined, will force the datetime to render the month + * containing the specified date. Currently, this should only + * be used to enable immediately auto-scrolling to the new month, + * and should then be reset to undefined once the transition is + * finished and the forced month is now in view. + * + * Applies to grid-style datetimes only. + */ + @State() forceRenderDate?: DatetimeParts; + /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -221,6 +228,12 @@ export class Datetime implements ComponentInterface { */ @Prop() presentation: DatetimePresentation = 'date-time'; + private get isGridStyle() { + const { presentation, preferWheel } = this; + const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; + return hasDatePresentation && !preferWheel; + } + /** * The text to display on the picker's cancel button. */ @@ -302,11 +315,6 @@ export class Datetime implements ComponentInterface { this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues); } - @Watch('activeParts') - protected activePartsChanged() { - this.activePartsClone = this.activeParts; - } - /** * The locale to use for `ion-datetime`. This * impacts month and day name formatting. @@ -356,54 +364,11 @@ export class Datetime implements ComponentInterface { * Update the datetime value when the value changes */ @Watch('value') - protected valueChanged() { - const { value, minParts, maxParts, workingParts } = this; + protected async valueChanged() { + const { value } = this; if (this.hasValue()) { - this.warnIfIncorrectValueUsage(); - - /** - * Clones the value of the `activeParts` to the private clone, to update - * the date display on the current render cycle without causing another render. - * - * This allows us to update the current value's date/time display without - * refocusing or shifting the user's display (leaves the user in place). - */ - const valueDateParts = parseDate(value); - if (valueDateParts) { - warnIfValueOutOfBounds(valueDateParts, minParts, maxParts); - - if (Array.isArray(valueDateParts)) { - this.activePartsClone = [...valueDateParts]; - } else { - const { month, day, year, hour, minute } = valueDateParts; - const ampm = hour != null ? (hour >= 12 ? 'pm' : 'am') : undefined; - - this.activePartsClone = { - ...this.activeParts, - month, - day, - year, - hour, - minute, - ampm, - }; - - /** - * The working parts am/pm value must be updated when the value changes, to - * ensure the time picker hour column values are generated correctly. - * - * Note that we don't need to do this if valueDateParts is an array, since - * multiple="true" does not apply to time pickers. - */ - this.setWorkingParts({ - ...workingParts, - ampm, - }); - } - } else { - printIonWarning(`Unable to parse date string: ${value}. Please provide a valid ISO 8601 datetime string.`); - } + this.processValue(value); } this.emitStyle(); @@ -596,9 +561,9 @@ export class Datetime implements ComponentInterface { * data. This should be used when rendering an * interface in an environment where the `value` * may not be set. This function works - * by returning the first selected date in - * "activePartsClone" and then falling back to - * defaultParts if no active date is selected. + * by returning the first selected date and then + * falling back to defaultParts if no active date + * is selected. */ private getActivePartsWithFallback = () => { const { defaultParts } = this; @@ -606,8 +571,8 @@ export class Datetime implements ComponentInterface { }; private getActivePart = () => { - const { activePartsClone } = this; - return Array.isArray(activePartsClone) ? activePartsClone[0] : activePartsClone; + const { activeParts } = this; + return Array.isArray(activeParts) ? activeParts[0] : activeParts; }; private closeParentOverlay = () => { @@ -627,7 +592,7 @@ export class Datetime implements ComponentInterface { }; private setActiveParts = (parts: DatetimeParts, removeDate = false) => { - const { multiple, minParts, maxParts, activePartsClone } = this; + const { multiple, minParts, maxParts, activeParts } = this; /** * When setting the active parts, it is possible @@ -643,16 +608,7 @@ export class Datetime implements ComponentInterface { this.setWorkingParts(validatedParts); if (multiple) { - /** - * We read from activePartsClone here because valueChanged() only updates that, - * so it's the more reliable source of truth. If we read from activeParts, then - * if you click July 1, manually set the value to July 2, and then click July 3, - * the new value would be [July 1, July 3], ignoring the value set. - * - * We can then pass the new value to activeParts (rather than activePartsClone) - * since the clone will be updated automatically by activePartsChanged(). - */ - const activePartsArray = Array.isArray(activePartsClone) ? activePartsClone : [activePartsClone]; + const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts]; if (removeDate) { this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts)); } else { @@ -908,6 +864,20 @@ export class Datetime implements ComponentInterface { const monthBox = month.getBoundingClientRect(); if (Math.abs(monthBox.x - box.x) > 2) return; + /** + * If we're force-rendering a month, assume we've + * scrolled to that and return it. + * + * If forceRenderDate is ever used in a context where the + * forced month is not immediately auto-scrolled to, this + * should be updated to also check whether `month` has the + * same month and year as the forced date. + */ + const { forceRenderDate } = this; + if (forceRenderDate !== undefined) { + return { month: forceRenderDate.month, year: forceRenderDate.year, day: forceRenderDate.day }; + } + /** * From here, we can determine if the start * month or the end month was scrolled into view. @@ -976,6 +946,10 @@ export class Datetime implements ComponentInterface { calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1); calendarBodyRef.style.removeProperty('overflow'); + + if (this.resolveForceDateScrolling) { + this.resolveForceDateScrolling(); + } }); }; @@ -1193,13 +1167,21 @@ export class Datetime implements ComponentInterface { } private processValue = (value?: string | string[] | null) => { - const hasValue = value !== null && value !== undefined; + const hasValue = value !== null && value !== undefined && (!Array.isArray(value) || value.length > 0); const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; - const { minParts, maxParts } = this; + const { minParts, maxParts, workingParts, el } = this; this.warnIfIncorrectValueUsage(); + /** + * Return early if the value wasn't parsed correctly, such as + * if an improperly formatted date string was provided. + */ + if (!valueToProcess) { + return; + } + /** * Datetime should only warn of out of bounds values * if set by the user. If the `value` is undefined, @@ -1218,19 +1200,11 @@ export class Datetime implements ComponentInterface { * that the values don't necessarily have to be in order. */ const singleValue = Array.isArray(valueToProcess) ? valueToProcess[0] : valueToProcess; + const targetValue = clampDate(singleValue, minParts, maxParts); - const { month, day, year, hour, minute } = clampDate(singleValue, minParts, maxParts); + const { month, day, year, hour, minute } = targetValue; const ampm = parseAmPm(hour!); - this.setWorkingParts({ - month, - day, - year, - hour, - minute, - ampm, - }); - /** * Since `activeParts` indicates a value that * been explicitly selected either by the @@ -1258,6 +1232,67 @@ export class Datetime implements ComponentInterface { */ this.activeParts = []; } + + /** + * Only animate if: + * 1. We're using grid style (wheel style pickers should just jump to new value) + * 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to) + * 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example) + * 4. The month/year picker is not open (since you wouldn't see the animation anyway) + */ + const didChangeMonth = + (month !== undefined && month !== workingParts.month) || (year !== undefined && year !== workingParts.year); + const bodyIsVisible = el.classList.contains('datetime-ready'); + const { isGridStyle, showMonthAndYear } = this; + if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) { + this.animateToDate(targetValue); + } else { + /** + * We only need to do this if we didn't just animate to a new month, + * since that calls prevMonth/nextMonth which calls setWorkingParts for us. + */ + this.setWorkingParts({ + month, + day, + year, + hour, + minute, + ampm, + }); + } + }; + + private animateToDate = async (targetValue: DatetimeParts) => { + const { workingParts } = this; + + /** + * Tell other render functions that we need to force the + * target month to appear in place of the actual next/prev month. + * Because this is a State variable, a rerender will be triggered + * automatically, updating the rendered months. + */ + this.forceRenderDate = targetValue; + + /** + * Flag that we've started scrolling to the forced date. + * The resolve function will be called by the datetime's + * scroll listener when it's done updating everything. + * This is a replacement for making prev/nextMonth async, + * since the logic we're waiting on is in a listener. + */ + const forceDateScrollingPromise = new Promise((resolve) => { + this.resolveForceDateScrolling = resolve; + }); + + /** + * Animate smoothly to the forced month. This will also update + * workingParts and correct the surrounding months for us. + */ + const targetMonthIsBefore = isBefore(targetValue, workingParts); + targetMonthIsBefore ? this.prevMonth() : this.nextMonth(); + await forceDateScrollingPromise; + this.resolveForceDateScrolling = undefined; + this.forceRenderDate = undefined; }; componentWillLoad() { @@ -1286,16 +1321,18 @@ export class Datetime implements ComponentInterface { } } - this.processMinParts(); - this.processMaxParts(); const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues)); const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues)); const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues)); const yearValues = (this.parsedYearValues = convertToArrayOfNumbers(this.yearValues)); const dayValues = (this.parsedDayValues = convertToArrayOfNumbers(this.dayValues)); - const todayParts = (this.todayParts = parseDate(getToday())); + const todayParts = (this.todayParts = parseDate(getToday())!); this.defaultParts = getClosestValidDate(todayParts, monthValues, dayValues, yearValues, hourValues, minuteValues); + + this.processMinParts(); + this.processMaxParts(); + this.processValue(this.value); this.emitStyle(); @@ -2042,7 +2079,7 @@ export class Datetime implements ComponentInterface { const { isActive, isToday, ariaLabel, ariaSelected, disabled, text } = getCalendarDayState( this.locale, referenceParts, - this.activePartsClone, + this.activeParts, this.todayParts, this.minParts, this.maxParts, @@ -2151,7 +2188,7 @@ export class Datetime implements ComponentInterface { private renderCalendarBody() { return (
(this.calendarBodyRef = el)} tabindex="0"> - {generateMonths(this.workingParts).map(({ month, year }) => { + {generateMonths(this.workingParts, this.forceRenderDate).map(({ month, year }) => { return this.renderMonth(month, year); })}
@@ -2360,7 +2397,19 @@ export class Datetime implements ComponentInterface { } render() { - const { name, value, disabled, el, color, readonly, showMonthAndYear, preferWheel, presentation, size } = this; + const { + name, + value, + disabled, + el, + color, + readonly, + showMonthAndYear, + preferWheel, + presentation, + size, + isGridStyle, + } = this; const mode = getIonMode(this); const isMonthAndYearPresentation = presentation === 'year' || presentation === 'month' || presentation === 'month-year'; @@ -2368,7 +2417,6 @@ export class Datetime implements ComponentInterface { const monthYearPickerOpen = showMonthAndYear && !isMonthAndYearPresentation; const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; const hasWheelVariant = hasDatePresentation && preferWheel; - const hasGrid = hasDatePresentation && !preferWheel; renderHiddenInput(true, el, name, formatValue(value), disabled); @@ -2387,7 +2435,7 @@ export class Datetime implements ComponentInterface { [`datetime-presentation-${presentation}`]: true, [`datetime-size-${size}`]: true, [`datetime-prefer-wheel`]: hasWheelVariant, - [`datetime-grid`]: hasGrid, + [`datetime-grid`]: isGridStyle, }), }} > diff --git a/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts b/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts index 9d9f2cf24ba..f00c313889b 100644 --- a/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts +++ b/core/src/components/datetime/test/prefer-wheel/datetime.e2e.ts @@ -244,6 +244,30 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await ionChange.next(); }); + + test('should jump to selected date when programmatically updating value', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + await page.waitForSelector('.datetime-ready'); + const datetime = page.locator('ion-datetime'); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-05-25T12:40:00.000Z')); + await page.waitForChanges(); + + const selectedMonth = datetime.locator('.month-column .picker-item-active'); + const selectedDay = datetime.locator('.day-column .picker-item-active'); + const selectedYear = datetime.locator('.year-column .picker-item-active'); + + await expect(selectedMonth).toHaveText(/May/); + await expect(selectedDay).toHaveText(/25/); + await expect(selectedYear).toHaveText(/2021/); + }); + test.describe('datetime: date wheel localization', () => { test('should correctly localize the date data', async ({ page }) => { await page.setContent( diff --git a/core/src/components/datetime/test/set-value/datetime.e2e.ts b/core/src/components/datetime/test/set-value/datetime.e2e.ts index 4012605fa5b..798b87144a1 100644 --- a/core/src/components/datetime/test/set-value/datetime.e2e.ts +++ b/core/src/components/datetime/test/set-value/datetime.e2e.ts @@ -15,6 +15,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const activeDate = page.locator('ion-datetime .calendar-day-active'); await expect(activeDate).toHaveText('25'); }); + test('should update the active time when value is initially set', async ({ page }) => { await page.goto('/src/components/datetime/test/set-value', config); await page.waitForSelector('.datetime-ready'); @@ -27,6 +28,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const activeDate = page.locator('ion-datetime .time-body'); await expect(activeDate).toHaveText('12:40 PM'); }); + test('should update active item when value is not initially set', async ({ page }) => { await page.setContent( ` @@ -39,23 +41,8 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const datetime = page.locator('ion-datetime'); const activeDayButton = page.locator('.calendar-day-active'); - const monthYearButton = page.locator('.calendar-month-year'); - const monthColumn = page.locator('.month-column'); - const ionChange = await page.spyOnEvent('ionChange'); await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-10-05')); - - // Open month/year picker - await monthYearButton.click(); - await page.waitForChanges(); - - // Select October 2021 - // The year will automatically switch to 2021 when selecting 10 - await monthColumn.locator('.picker-item[data-value="10"]').click(); - await ionChange.next(); - - // Close month/year picker - await monthYearButton.click(); await page.waitForChanges(); // Check that correct day is highlighted @@ -63,5 +50,17 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await expect(activeDayButton).toHaveAttribute('data-month', '10'); await expect(activeDayButton).toHaveAttribute('data-year', '2021'); }); + + test('should scroll to new month when value is initially set and then updated', async ({ page }) => { + await page.goto('/src/components/datetime/test/set-value', config); + await page.waitForSelector('.datetime-ready'); + + const datetime = page.locator('ion-datetime'); + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-05-25T12:40:00.000Z')); + await page.waitForChanges(); + + const calendarHeader = datetime.locator('.calendar-month-year'); + await expect(calendarHeader).toHaveText(/May 2021/); + }); }); }); diff --git a/core/src/components/datetime/utils/data.ts b/core/src/components/datetime/utils/data.ts index a4609a9be94..faf782f202e 100644 --- a/core/src/components/datetime/utils/data.ts +++ b/core/src/components/datetime/utils/data.ts @@ -254,12 +254,23 @@ export const generateTime = ( * Given DatetimeParts, generate the previous, * current, and and next months. */ -export const generateMonths = (refParts: DatetimeParts): DatetimeParts[] => { - return [ - getPreviousMonth(refParts), - { month: refParts.month, year: refParts.year, day: refParts.day }, - getNextMonth(refParts), - ]; +export const generateMonths = (refParts: DatetimeParts, forcedDate?: DatetimeParts): DatetimeParts[] => { + const current = { month: refParts.month, year: refParts.year, day: refParts.day }; + + /** + * If we're forcing a month to appear, and it's different from the current month, + * ensure it appears by replacing the next or previous month as appropriate. + */ + if (forcedDate !== undefined && (refParts.month !== forcedDate.month || refParts.year !== forcedDate.year)) { + const forced = { month: forcedDate.month, year: forcedDate.year, day: forcedDate.day }; + const forcedMonthIsBefore = isBefore(forced, current); + + return forcedMonthIsBefore + ? [forced, current, getNextMonth(refParts)] + : [getPreviousMonth(refParts), current, forced]; + } + + return [getPreviousMonth(refParts), current, getNextMonth(refParts)]; }; export const getMonthColumnData = ( diff --git a/core/src/components/datetime/utils/parse.ts b/core/src/components/datetime/utils/parse.ts index fda79085ac5..6904462f849 100644 --- a/core/src/components/datetime/utils/parse.ts +++ b/core/src/components/datetime/utils/parse.ts @@ -1,3 +1,5 @@ +import { printIonWarning } from '@utils/logging'; + import type { DatetimeParts } from '../datetime-interface'; import { isAfter, isBefore } from './comparison'; @@ -56,14 +58,32 @@ export const getPartsFromCalendarDay = (el: HTMLElement): DatetimeParts => { * We do not use the JS Date object here because * it adjusts the date for the current timezone. */ -export function parseDate(val: string): DatetimeParts; -export function parseDate(val: string[]): DatetimeParts[]; +export function parseDate(val: string): DatetimeParts | undefined; +export function parseDate(val: string[]): DatetimeParts[] | undefined; export function parseDate(val: undefined | null): undefined; -export function parseDate(val: string | string[]): DatetimeParts | DatetimeParts[]; +export function parseDate(val: string | string[]): DatetimeParts | DatetimeParts[] | undefined; export function parseDate(val: string | string[] | undefined | null): DatetimeParts | DatetimeParts[] | undefined; export function parseDate(val: string | string[] | undefined | null): DatetimeParts | DatetimeParts[] | undefined { if (Array.isArray(val)) { - return val.map((valStr) => parseDate(valStr)); + const parsedArray: DatetimeParts[] = []; + for (const valStr of val) { + const parsedVal = parseDate(valStr); + + /** + * If any of the values weren't parsed correctly, consider + * the entire batch incorrect. This simplifies the type + * signatures by having "undefined" be a general error case + * instead of returning (Datetime | undefined)[], which is + * harder for TS to perform type narrowing on. + */ + if (!parsedVal) { + return undefined; + } + + parsedArray.push(parsedVal); + } + + return parsedArray; } // manually parse IS0 cuz Date.parse cannot be trusted @@ -85,6 +105,7 @@ export function parseDate(val: string | string[] | undefined | null): DatetimePa if (parse === null) { // wasn't able to parse the ISO datetime + printIonWarning(`Unable to parse date string: ${val}. Please provide a valid ISO 8601 datetime string.`); return undefined; } @@ -132,8 +153,10 @@ export const parseAmPm = (hour: number) => { * For example, max="2012" would fill in the missing * month, day, hour, and minute information. */ -export const parseMaxParts = (max: string, todayParts: DatetimeParts): DatetimeParts => { - const { month, day, year, hour, minute } = parseDate(max); +export const parseMaxParts = (max: string, todayParts: DatetimeParts): DatetimeParts | undefined => { + const parsedMax = parseDate(max); + if (!parsedMax) return; + const { month, day, year, hour, minute } = parsedMax; /** * When passing in `max` or `min`, developers @@ -168,8 +191,10 @@ export const parseMaxParts = (max: string, todayParts: DatetimeParts): DatetimeP * For example, min="2012" would fill in the missing * month, day, hour, and minute information. */ -export const parseMinParts = (min: string, todayParts: DatetimeParts): DatetimeParts => { - const { month, day, year, hour, minute } = parseDate(min); +export const parseMinParts = (min: string, todayParts: DatetimeParts): DatetimeParts | undefined => { + const parsedMin = parseDate(min); + if (!parsedMin) return; + const { month, day, year, hour, minute } = parsedMin; /** * When passing in `max` or `min`, developers