Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@ ion-datetime,prop,value,null | string | string[] | undefined,undefined,false,fal
ion-datetime,prop,yearValues,number | number[] | string | undefined,undefined,false,false
ion-datetime,method,cancel,cancel(closeOverlay?: boolean) => Promise<void>
ion-datetime,method,confirm,confirm(closeOverlay?: boolean) => Promise<void>
ion-datetime,method,getClosestDate,getClosestDate(date: Date) => Promise<Date>
ion-datetime,method,reset,reset(startDate?: string) => Promise<void>
ion-datetime,event,ionBlur,void,true
ion-datetime,event,ionCancel,void,true
Expand Down
5 changes: 5 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,11 @@ export namespace Components {
* Formatting options for dates and times. Should include a 'date' and/or 'time' object, each of which is of type [Intl.DateTimeFormatOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options).
*/
"formatOptions"?: FormatOptions;
/**
* Get the closest valid Date according to the restrictions on this Datetime
* @param date The Date to find the closest valid value for
*/
"getClosestDate": (date: Date) => Promise<Date>;
/**
* Used to apply custom text and background colors to specific dates. Can be either an array of objects containing ISO strings and colors, or a callback that receives an ISO string and returns the colors. Only applies to the `date`, `date-time`, and `time-date` presentations, with `preferWheel="false"`.
*/
Expand Down
10 changes: 5 additions & 5 deletions core/src/components/datetime-button/datetime-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { createColorClasses } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { Color } from '../../interface';
import type { DatetimePresentation } from '../datetime/datetime-interface';
import { getToday } from '../datetime/utils/data';
import { getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format';
import { getHourCycle } from '../datetime/utils/helpers';
import { parseDate } from '../datetime/utils/parse';
Expand Down Expand Up @@ -125,7 +124,7 @@ export class DatetimeButton implements ComponentInterface {
overlayEl.classList.add('ion-datetime-button-overlay');
}

componentOnReady(datetimeEl, () => {
componentOnReady(datetimeEl, async () => {
const datetimePresentation = (this.datetimePresentation = datetimeEl.presentation || 'date-time');

/**
Expand All @@ -138,7 +137,7 @@ export class DatetimeButton implements ComponentInterface {
* to re-render the displayed
* text in the buttons.
*/
this.setDateTimeText();
await this.setDateTimeText();
addEventListener(datetimeEl, 'ionValueChange', this.setDateTimeText);

/**
Expand Down Expand Up @@ -189,7 +188,7 @@ export class DatetimeButton implements ComponentInterface {
* ion-datetime and then format it according
* to the locale specified on ion-datetime.
*/
private setDateTimeText = () => {
private setDateTimeText = async () => {
const { datetimeEl, datetimePresentation } = this;

if (!datetimeEl) {
Expand All @@ -204,7 +203,8 @@ export class DatetimeButton implements ComponentInterface {
* Both ion-datetime and ion-datetime-button default
* to today's date and time if no value is set.
*/
const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]);
const defaultDatetime = [(await datetimeEl.getClosestDate(new Date())).toISOString()];
const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : defaultDatetime);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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);


if (!parsedDatetimes) {
return;
Expand Down
64 changes: 47 additions & 17 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ import {
getTimeColumnsData,
getCombinedDateColumnData,
} from './utils/data';
import { formatValue, getLocalizedDateTime, getLocalizedTime, getMonthAndYear } from './utils/format';
import {
formatValue,
getLocalizedDateTime,
getLocalizedTime,
getMonthAndYear,
removeDateTzOffset,
} from './utils/format';
import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers';
import {
calculateHourFromAMPM,
Expand Down Expand Up @@ -604,6 +610,45 @@ export class Datetime implements ComponentInterface {
}
}

/**
* Get the closest valid DatetimeParts according to the restrictions on this Datetime
* @param parts The DatetimeParts to find the closest valid value for
*/
private getClosestDatetimeParts(parts: DatetimeParts) {
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));
return getClosestValidDate({
refParts: parts,
monthValues,
dayValues,
yearValues,
hourValues,
minuteValues,
minParts: this.minParts,
maxParts: this.maxParts,
});
}

/**
* 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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DatetimeParts and the native Date API don't use the same conventions, and two fields are mismatched here:

  1. month is off by one. Date.getMonth() is 0-indexed (January is 0), but DatetimeParts.month is 1-indexed (January is 1). So today, June, comes through as month 5, which DatetimeParts reads as May.

  2. day is 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 from Date.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:

Suggested change
month: date.getMonth(),
day: date.getDay(),
month: date.getMonth() + 1,
day: date.getDate(),

year: date.getFullYear(),
dayOfWeek: date.getDay(),
hour: date.getHours(),
minute: date.getMinutes(),
});
return removeDateTzOffset(new Date(convertDataToISO(closest)));
}
Comment on lines +629 to +639

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/**
* 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()];


private warnIfIncorrectValueUsage = () => {
const { multiple, value } = this;
if (!multiple && Array.isArray(value)) {
Expand Down Expand Up @@ -1495,27 +1540,12 @@ export class Datetime implements ComponentInterface {
warnIfTimeZoneProvided(el, formatOptions);
}

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())!);

this.processMinParts();
this.processMaxParts();

this.defaultParts = getClosestValidDate({
refParts: todayParts,
monthValues,
dayValues,
yearValues,
hourValues,
minuteValues,
minParts: this.minParts,
maxParts: this.maxParts,
});
this.defaultParts = this.getClosestDatetimeParts(todayParts);

this.processValue(this.value);

Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ Set `scrollEvents` to `true` to enable.

@ProxyCmp({
inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showAdjacentDays', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
methods: ['confirm', 'reset', 'cancel']
methods: ['confirm', 'reset', 'cancel', 'getClosestDate']
})
@Component({
selector: 'ion-datetime',
Expand Down
Loading