fix(datetime-button): fix initial text not obeying datetime constraints#31218
fix(datetime-button): fix initial text not obeying datetime constraints#31218droc101 wants to merge 5 commits into
Conversation
…aints expose new `getClosestDate(date: Date) => Promise<Date>` function in the Datetime class and use it in DatetimeButton to round the current time (default value) into a date that matches the constraints provided on the Datetime element (dayValues, minuteValues, etc.) closes ionic-team#30183
|
@droc101 is attempting to deploy a commit to the Ionic Team on Vercel. A member of the Team first needs to authorize it. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
thetaPC
left a comment
There was a problem hiding this comment.
Please also add tests to prevent any regressions in the future. I would recommend adding them to datetime-button/test/basic/datetime-button.e2e.ts:
presentation="date"with no constraints — guards the plain day/month fallback (this is the case the current bug breaks).presentation="time"withminute-values="0"— the originally reported case from #30183.- At least one other constraint, e.g.
hour-valuesormonth-values, to confirm it's not minute-specific.
| month: date.getMonth(), | ||
| day: date.getDay(), |
There was a problem hiding this comment.
DatetimeParts and the native Date API don't use the same conventions, and two fields are mismatched here:
-
monthis off by one.Date.getMonth()is 0-indexed (January is0), butDatetimeParts.monthis 1-indexed (January is1). So today, June, comes through as month 5, whichDatetimePartsreads as May. -
dayis the wrong field entirely.Date.getDay()returns the day of the week (0–6, Sun–Sat), not the day of the month. The day of the month comes fromDate.getDate().
You can reproduce it with a plain no-value date picker (no constraints needed): the calendar lands on Jun 17, 2026 but the button reads "May 3, 2026".
Both of these can be seen with:
<ion-item>
<ion-label>Start Date</ion-label>
<ion-datetime-button slot="end" datetime="no-value-date"></ion-datetime-button>
</ion-item>
<!-- keep-contents-mounted makes the datetime (and the button text) compute on load, so the mismatch shows immediately -->
<ion-modal keep-contents-mounted="true">
<ion-datetime locale="en-US" presentation="date" id="no-value-date"></ion-datetime>
</ion-modal>Fix:
| month: date.getMonth(), | |
| day: date.getDay(), | |
| month: date.getMonth() + 1, | |
| day: date.getDate(), |
| const defaultDatetime = [(await datetimeEl.getClosestDate(new Date())).toISOString()]; | ||
| const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : defaultDatetime); |
There was a problem hiding this comment.
getClosestDate is awaited on every call, but the result is thrown away whenever parsedValues is non-empty (i.e. whenever the datetime has a value). Since getClosestDate is a Stencil @Method, this is an async cross-component round-trip on every ionValueChange, just to discard the result. Move it into the no-value branch and also update the comment:
| const defaultDatetime = [(await datetimeEl.getClosestDate(new Date())).toISOString()]; | |
| const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : defaultDatetime); | |
| /** | |
| * Both ion-datetime and ion-datetime-button default to today's date | |
| * and time if no value is set. The datetime is queried for the closest | |
| * valid value so the button respects the same constraints (min, max, | |
| * minuteValues, etc.) that the datetime applies to its own default. | |
| */ | |
| let valuesToParse = parsedValues; | |
| if (valuesToParse.length === 0) { | |
| const closestDate = await datetimeEl.getClosestDate(new Date()); | |
| valuesToParse = [closestDate.toISOString()]; | |
| } | |
| const parsedDatetimes = parseDate(valuesToParse); |
| /** | ||
| * Get the closest valid Date according to the restrictions on this Datetime | ||
| * @param date The Date to find the closest valid value for | ||
| */ | ||
| @Method() | ||
| async getClosestDate(date: Date) { | ||
| const closest = this.getClosestDatetimeParts({ | ||
| month: date.getMonth(), | ||
| day: date.getDay(), | ||
| year: date.getFullYear(), | ||
| dayOfWeek: date.getDay(), | ||
| hour: date.getHours(), | ||
| minute: date.getMinutes(), | ||
| }); | ||
| return removeDateTzOffset(new Date(convertDataToISO(closest))); | ||
| } |
There was a problem hiding this comment.
I'd recommend an @internal method instead of a new public one.
componentWillLoad already computes this.defaultParts (today snapped to the closest valid value via getClosestValidDate, datetime.tsx:1540) — exactly what the button is trying to reconstruct. So the button can just read that value rather than recomputing it from a Date, which also guarantees the button and picker never disagree.
Making it @internal follows how these two already communicate (the button listens to the @internal ionValueChange event), and avoids adding public API to document and support. It also avoids reconstructing parts from a Date (the ISO -> removeDateTzOffset round-trip), which is where the getMonth()/getDay() mistakes lived.
| /** | |
| * Get the closest valid Date according to the restrictions on this Datetime | |
| * @param date The Date to find the closest valid value for | |
| */ | |
| @Method() | |
| async getClosestDate(date: Date) { | |
| const closest = this.getClosestDatetimeParts({ | |
| month: date.getMonth(), | |
| day: date.getDay(), | |
| year: date.getFullYear(), | |
| dayOfWeek: date.getDay(), | |
| hour: date.getHours(), | |
| minute: date.getMinutes(), | |
| }); | |
| return removeDateTzOffset(new Date(convertDataToISO(closest))); | |
| } | |
| /** | |
| * Returns the default parts the datetime falls back to when no value is set: | |
| * today's date and time snapped to the closest value allowed by the | |
| * component's constraints (`min`, `max`, and the `*Values` props). | |
| * | |
| * @internal | |
| */ | |
| @Method() | |
| async getDefaultPart(): Promise<DatetimeParts> { | |
| return this.defaultParts; | |
| } |
The datetime-button.tsx then reads it in the no-value branch
/**
* Both ion-datetime and ion-datetime-button default to today's date and
* time if no value is set. We read the datetime's computed default so the
* button respects the same constraints (min, max, minuteValues, etc.) that
* the datetime applies to its own fallback, instead of using a raw "now".
*/
const parsedDatetimes =
parsedValues.length > 0 ? parseDate(parsedValues) : [await datetimeEl.getDefaultPart()];…method returning datetime's defaultParts
|
Tests will be coming in a separate future commit, have to figure out how that side of the code works. |
|
@droc101 I highly recommend reviewing the E2E testing doc. |
- should default to exact current time with no constraints - should obey minuteValues constraint - should obey hourValues constraint - should obey monthValues constraint
Issue number: resolves #30183
What is the current behavior?
an
IonDatetimeButton's default value (such as when .value is undefined) always uses the exact current date & time, regardless of any restrictions requested by the associatedIonDatetime(such as only allowing 5 minute increments)What is the new behavior?
IonDatetimeButtonnow follows the constraints on its associatedIonDatetime.valueof theIonDatetimeButtonis unchangedasync getClosestDate(date: Date) => Promise<Date>has been added toIonDatetimeto facilitate this change. If this should not be a public method, I can work on changing that.getClosestDatetimeParts(parts: DatetimeParts) => DatetimePartshas been added toIonDatetimeas a helper to the previously mentionedgetClosestDatemethod, and to deduplicate code.Does this introduce a breaking change?