mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-07 23:16:52 +08:00
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:
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user