fix(datetime): scroll to newly selected date when value changes (#27806)

Issue number: Resolves #26391

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

When updating the `value` programmatically on an `ion-datetime` after it
has already been created:
- With grid style: The selected date visually updates, but the calendar
does not scroll to the newly selected month.
- With wheel style: The selected date does not visually update, i.e. the
wheels do not move to show the newly selected date.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Grid style datetimes now scroll to the selected date using the same
animation as when clicking the next/prev month buttons.
- This animation mirrors the behavior in both MUI and native iOS. See
the [design
doc](https://github.com/ionic-team/ionic-framework-design-documents/blob/main/projects/ionic-framework/components/datetime/0003-datetime-async-value.md)
for more information and screen recordings.
- The animation will not occur if the month/year did not change, or when
the datetime is hidden.
- Wheel style datetimes now visually update to the selected date. No
animation occurs, also mirroring native.
- The `parseDate` util has also had its type signatures updated to
account for returning `undefined` when the date string is improperly
formatted. This was missed when the util was refactored to support
multiple date selection.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

- Docs PR: https://github.com/ionic-team/ionic-docs/pull/3053
- While this can technically be considered a bug fix, we are merging it
into a feature branch for safety; it's a fairly significant change to
how datetime behaves, and may interfere with custom logic when updating
a datetime's value async.
- Jumping to the newly selected value is handled by replacing everything
[here](https://github.com/ionic-team/ionic-framework/pull/27806/files#diff-4a407530c60e3cf72bcc11acdd21c4803a94bf47ea81b99e757db1c93d2735b8L364-L407)
with `processValue()`. This covers both wheel and grid datetimes.
- `activePartsClone` as a whole was also removed. It was added in
https://github.com/ionic-team/ionic-framework/pull/24244 to enable
changing `activeParts` without triggering a rerender (and thus jumping
to the new value) but since we now want to do that jump, the clone is no
longer needed.
- The animation code might be tricky to follow, so I recorded going
through it:
https://github.com/ionic-team/ionic-framework/assets/90629384/1afa5762-f493-441a-b662-f0429f2d86a7
This commit is contained in:
Amanda Johnston
2023-08-23 13:49:19 -05:00
committed by GitHub
parent ae9f1ab43e
commit 32244fbdd1
6 changed files with 232 additions and 121 deletions

View File

@ -206,6 +206,10 @@ export class DatetimeButton implements ComponentInterface {
*/ */
const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]); const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]);
if (!parsedDatetimes) {
return;
}
/** /**
* If developers incorrectly use multiple="true" * If developers incorrectly use multiple="true"
* with non "date" datetimes, then just select * with non "date" datetimes, then just select

View File

@ -117,11 +117,7 @@ export class Datetime implements ComponentInterface {
private prevPresentation: string | null = null; private prevPresentation: string | null = null;
/** private resolveForceDateScrolling?: () => void;
* 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[] = [];
@State() showMonthAndYear = false; @State() showMonthAndYear = false;
@ -140,6 +136,17 @@ export class Datetime implements ComponentInterface {
@State() isTimePopoverOpen = false; @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. * The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. * 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'; @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. * The text to display on the picker's cancel button.
*/ */
@ -302,11 +315,6 @@ export class Datetime implements ComponentInterface {
this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues); this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues);
} }
@Watch('activeParts')
protected activePartsChanged() {
this.activePartsClone = this.activeParts;
}
/** /**
* The locale to use for `ion-datetime`. This * The locale to use for `ion-datetime`. This
* impacts month and day name formatting. * impacts month and day name formatting.
@ -356,54 +364,11 @@ export class Datetime implements ComponentInterface {
* Update the datetime value when the value changes * Update the datetime value when the value changes
*/ */
@Watch('value') @Watch('value')
protected valueChanged() { protected async valueChanged() {
const { value, minParts, maxParts, workingParts } = this; const { value } = this;
if (this.hasValue()) { if (this.hasValue()) {
this.warnIfIncorrectValueUsage(); this.processValue(value);
/**
* 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.emitStyle(); this.emitStyle();
@ -596,9 +561,9 @@ export class Datetime implements ComponentInterface {
* data. This should be used when rendering an * data. This should be used when rendering an
* interface in an environment where the `value` * interface in an environment where the `value`
* may not be set. This function works * may not be set. This function works
* by returning the first selected date in * by returning the first selected date and then
* "activePartsClone" and then falling back to * falling back to defaultParts if no active date
* defaultParts if no active date is selected. * is selected.
*/ */
private getActivePartsWithFallback = () => { private getActivePartsWithFallback = () => {
const { defaultParts } = this; const { defaultParts } = this;
@ -606,8 +571,8 @@ export class Datetime implements ComponentInterface {
}; };
private getActivePart = () => { private getActivePart = () => {
const { activePartsClone } = this; const { activeParts } = this;
return Array.isArray(activePartsClone) ? activePartsClone[0] : activePartsClone; return Array.isArray(activeParts) ? activeParts[0] : activeParts;
}; };
private closeParentOverlay = () => { private closeParentOverlay = () => {
@ -627,7 +592,7 @@ export class Datetime implements ComponentInterface {
}; };
private setActiveParts = (parts: DatetimeParts, removeDate = false) => { 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 * When setting the active parts, it is possible
@ -643,16 +608,7 @@ export class Datetime implements ComponentInterface {
this.setWorkingParts(validatedParts); this.setWorkingParts(validatedParts);
if (multiple) { if (multiple) {
/** const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts];
* 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];
if (removeDate) { if (removeDate) {
this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts)); this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts));
} else { } else {
@ -908,6 +864,20 @@ export class Datetime implements ComponentInterface {
const monthBox = month.getBoundingClientRect(); const monthBox = month.getBoundingClientRect();
if (Math.abs(monthBox.x - box.x) > 2) return; 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 * From here, we can determine if the start
* month or the end month was scrolled into view. * 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.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1);
calendarBodyRef.style.removeProperty('overflow'); calendarBodyRef.style.removeProperty('overflow');
if (this.resolveForceDateScrolling) {
this.resolveForceDateScrolling();
}
}); });
}; };
@ -1193,13 +1167,21 @@ export class Datetime implements ComponentInterface {
} }
private processValue = (value?: string | string[] | null) => { 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 valueToProcess = hasValue ? parseDate(value) : this.defaultParts;
const { minParts, maxParts } = this; const { minParts, maxParts, workingParts, el } = this;
this.warnIfIncorrectValueUsage(); 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 * Datetime should only warn of out of bounds values
* if set by the user. If the `value` is undefined, * 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. * that the values don't necessarily have to be in order.
*/ */
const singleValue = Array.isArray(valueToProcess) ? valueToProcess[0] : valueToProcess; 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!); const ampm = parseAmPm(hour!);
this.setWorkingParts({
month,
day,
year,
hour,
minute,
ampm,
});
/** /**
* Since `activeParts` indicates a value that * Since `activeParts` indicates a value that
* been explicitly selected either by the * been explicitly selected either by the
@ -1258,6 +1232,67 @@ export class Datetime implements ComponentInterface {
*/ */
this.activeParts = []; 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<void>((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() { componentWillLoad() {
@ -1286,16 +1321,18 @@ export class Datetime implements ComponentInterface {
} }
} }
this.processMinParts();
this.processMaxParts();
const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues)); const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues));
const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues)); const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues));
const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues)); const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues));
const yearValues = (this.parsedYearValues = convertToArrayOfNumbers(this.yearValues)); const yearValues = (this.parsedYearValues = convertToArrayOfNumbers(this.yearValues));
const dayValues = (this.parsedDayValues = convertToArrayOfNumbers(this.dayValues)); 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.defaultParts = getClosestValidDate(todayParts, monthValues, dayValues, yearValues, hourValues, minuteValues);
this.processMinParts();
this.processMaxParts();
this.processValue(this.value); this.processValue(this.value);
this.emitStyle(); this.emitStyle();
@ -2042,7 +2079,7 @@ export class Datetime implements ComponentInterface {
const { isActive, isToday, ariaLabel, ariaSelected, disabled, text } = getCalendarDayState( const { isActive, isToday, ariaLabel, ariaSelected, disabled, text } = getCalendarDayState(
this.locale, this.locale,
referenceParts, referenceParts,
this.activePartsClone, this.activeParts,
this.todayParts, this.todayParts,
this.minParts, this.minParts,
this.maxParts, this.maxParts,
@ -2151,7 +2188,7 @@ export class Datetime implements ComponentInterface {
private renderCalendarBody() { private renderCalendarBody() {
return ( return (
<div class="calendar-body ion-focusable" ref={(el) => (this.calendarBodyRef = el)} tabindex="0"> <div class="calendar-body ion-focusable" ref={(el) => (this.calendarBodyRef = el)} tabindex="0">
{generateMonths(this.workingParts).map(({ month, year }) => { {generateMonths(this.workingParts, this.forceRenderDate).map(({ month, year }) => {
return this.renderMonth(month, year); return this.renderMonth(month, year);
})} })}
</div> </div>
@ -2360,7 +2397,19 @@ export class Datetime implements ComponentInterface {
} }
render() { 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 mode = getIonMode(this);
const isMonthAndYearPresentation = const isMonthAndYearPresentation =
presentation === 'year' || presentation === 'month' || presentation === 'month-year'; presentation === 'year' || presentation === 'month' || presentation === 'month-year';
@ -2368,7 +2417,6 @@ export class Datetime implements ComponentInterface {
const monthYearPickerOpen = showMonthAndYear && !isMonthAndYearPresentation; const monthYearPickerOpen = showMonthAndYear && !isMonthAndYearPresentation;
const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date';
const hasWheelVariant = hasDatePresentation && preferWheel; const hasWheelVariant = hasDatePresentation && preferWheel;
const hasGrid = hasDatePresentation && !preferWheel;
renderHiddenInput(true, el, name, formatValue(value), disabled); renderHiddenInput(true, el, name, formatValue(value), disabled);
@ -2387,7 +2435,7 @@ export class Datetime implements ComponentInterface {
[`datetime-presentation-${presentation}`]: true, [`datetime-presentation-${presentation}`]: true,
[`datetime-size-${size}`]: true, [`datetime-size-${size}`]: true,
[`datetime-prefer-wheel`]: hasWheelVariant, [`datetime-prefer-wheel`]: hasWheelVariant,
[`datetime-grid`]: hasGrid, [`datetime-grid`]: isGridStyle,
}), }),
}} }}
> >

View File

@ -244,6 +244,30 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await ionChange.next(); await ionChange.next();
}); });
test('should jump to selected date when programmatically updating value', async ({ page }) => {
await page.setContent(
`
<ion-datetime presentation="date" prefer-wheel="true" min="2019-05-05" max="2023-10-01" value="2019-05-30"></ion-datetime>
`,
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.describe('datetime: date wheel localization', () => {
test('should correctly localize the date data', async ({ page }) => { test('should correctly localize the date data', async ({ page }) => {
await page.setContent( await page.setContent(

View File

@ -15,6 +15,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const activeDate = page.locator('ion-datetime .calendar-day-active'); const activeDate = page.locator('ion-datetime .calendar-day-active');
await expect(activeDate).toHaveText('25'); await expect(activeDate).toHaveText('25');
}); });
test('should update the active time when value is initially set', async ({ page }) => { 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.goto('/src/components/datetime/test/set-value', config);
await page.waitForSelector('.datetime-ready'); 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'); const activeDate = page.locator('ion-datetime .time-body');
await expect(activeDate).toHaveText('12:40 PM'); await expect(activeDate).toHaveText('12:40 PM');
}); });
test('should update active item when value is not initially set', async ({ page }) => { test('should update active item when value is not initially set', async ({ page }) => {
await page.setContent( await page.setContent(
` `
@ -39,23 +41,8 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const datetime = page.locator('ion-datetime'); const datetime = page.locator('ion-datetime');
const activeDayButton = page.locator('.calendar-day-active'); 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')); 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(); await page.waitForChanges();
// Check that correct day is highlighted // 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-month', '10');
await expect(activeDayButton).toHaveAttribute('data-year', '2021'); 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/);
});
}); });
}); });

View File

@ -254,12 +254,23 @@ export const generateTime = (
* Given DatetimeParts, generate the previous, * Given DatetimeParts, generate the previous,
* current, and and next months. * current, and and next months.
*/ */
export const generateMonths = (refParts: DatetimeParts): DatetimeParts[] => { export const generateMonths = (refParts: DatetimeParts, forcedDate?: DatetimeParts): DatetimeParts[] => {
return [ const current = { month: refParts.month, year: refParts.year, day: refParts.day };
getPreviousMonth(refParts),
{ month: refParts.month, year: refParts.year, day: refParts.day }, /**
getNextMonth(refParts), * 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 = ( export const getMonthColumnData = (

View File

@ -1,3 +1,5 @@
import { printIonWarning } from '@utils/logging';
import type { DatetimeParts } from '../datetime-interface'; import type { DatetimeParts } from '../datetime-interface';
import { isAfter, isBefore } from './comparison'; 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 * We do not use the JS Date object here because
* it adjusts the date for the current timezone. * it adjusts the date for the current timezone.
*/ */
export function parseDate(val: string): DatetimeParts; export function parseDate(val: string): DatetimeParts | undefined;
export function parseDate(val: string[]): DatetimeParts[]; export function parseDate(val: string[]): DatetimeParts[] | undefined;
export function parseDate(val: undefined | null): 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;
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)) { 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 // 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) { if (parse === null) {
// wasn't able to parse the ISO datetime // 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; return undefined;
} }
@ -132,8 +153,10 @@ export const parseAmPm = (hour: number) => {
* For example, max="2012" would fill in the missing * For example, max="2012" would fill in the missing
* month, day, hour, and minute information. * month, day, hour, and minute information.
*/ */
export const parseMaxParts = (max: string, todayParts: DatetimeParts): DatetimeParts => { export const parseMaxParts = (max: string, todayParts: DatetimeParts): DatetimeParts | undefined => {
const { month, day, year, hour, minute } = parseDate(max); const parsedMax = parseDate(max);
if (!parsedMax) return;
const { month, day, year, hour, minute } = parsedMax;
/** /**
* When passing in `max` or `min`, developers * 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 * For example, min="2012" would fill in the missing
* month, day, hour, and minute information. * month, day, hour, and minute information.
*/ */
export const parseMinParts = (min: string, todayParts: DatetimeParts): DatetimeParts => { export const parseMinParts = (min: string, todayParts: DatetimeParts): DatetimeParts | undefined => {
const { month, day, year, hour, minute } = parseDate(min); const parsedMin = parseDate(min);
if (!parsedMin) return;
const { month, day, year, hour, minute } = parsedMin;
/** /**
* When passing in `max` or `min`, developers * When passing in `max` or `min`, developers