From f82ea68e9ecf370b88783135661e0248f3432f08 Mon Sep 17 00:00:00 2001 From: droc101 <37421449+droc101@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:55:09 -0500 Subject: [PATCH 1/4] fix(datetime-button): fix initial value not following datetime constraints expose new `getClosestDate(date: Date) => Promise` 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 #30183 --- core/api.txt | 1 + core/src/components.d.ts | 5 ++ .../datetime-button/datetime-button.tsx | 10 +-- core/src/components/datetime/datetime.tsx | 64 ++++++++++++++----- packages/angular/src/directives/proxies.ts | 2 +- 5 files changed, 59 insertions(+), 23 deletions(-) diff --git a/core/api.txt b/core/api.txt index 8a9af0b2918..0ea7bdb53d4 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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 ion-datetime,method,confirm,confirm(closeOverlay?: boolean) => Promise +ion-datetime,method,getClosestDate,getClosestDate(date: Date) => Promise ion-datetime,method,reset,reset(startDate?: string) => Promise ion-datetime,event,ionBlur,void,true ion-datetime,event,ionCancel,void,true diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 443c04611cf..7710da9f8d3 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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; /** * 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"`. */ diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index af0a77f88e0..965341116e4 100644 --- a/core/src/components/datetime-button/datetime-button.tsx +++ b/core/src/components/datetime-button/datetime-button.tsx @@ -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'; @@ -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'); /** @@ -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); /** @@ -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) { @@ -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); if (!parsedDatetimes) { return; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index f36cd66718d..d5621e20677 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -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, @@ -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(), + year: date.getFullYear(), + dayOfWeek: date.getDay(), + hour: date.getHours(), + minute: date.getMinutes(), + }); + return removeDateTzOffset(new Date(convertDataToISO(closest))); + } + private warnIfIncorrectValueUsage = () => { const { multiple, value } = this; if (!multiple && Array.isArray(value)) { @@ -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); diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 1c78b120d9d..d6b63a168ec 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -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', From 7d952692fcc5f8cccd12b4c8fea03eb21c7fdef0 Mon Sep 17 00:00:00 2001 From: droc101 <37421449+droc101@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:23:12 -0500 Subject: [PATCH 2/4] refactor(datetime-button): communicate default datetime via internal method returning datetime's defaultParts --- core/api.txt | 1 - core/src/components.d.ts | 9 ++++----- .../datetime-button/datetime-button.tsx | 12 +++++++---- core/src/components/datetime/datetime.tsx | 20 +++++++------------ packages/angular/src/directives/proxies.ts | 2 +- 5 files changed, 20 insertions(+), 24 deletions(-) diff --git a/core/api.txt b/core/api.txt index 0ea7bdb53d4..8a9af0b2918 100644 --- a/core/api.txt +++ b/core/api.txt @@ -545,7 +545,6 @@ 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 ion-datetime,method,confirm,confirm(closeOverlay?: boolean) => Promise -ion-datetime,method,getClosestDate,getClosestDate(date: Date) => Promise ion-datetime,method,reset,reset(startDate?: string) => Promise ion-datetime,event,ionBlur,void,true ion-datetime,event,ionCancel,void,true diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 7710da9f8d3..880f379e25c 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -15,7 +15,7 @@ import { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; -import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; import { SpinnerTypes } from "./components/spinner/spinner-configs"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; @@ -53,7 +53,7 @@ export { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; -export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; export { SpinnerTypes } from "./components/spinner/spinner-configs"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; @@ -962,10 +962,9 @@ export namespace Components { */ "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 + * 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). */ - "getClosestDate": (date: Date) => Promise; + "getDefaultPart": () => Promise; /** * 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"`. */ diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index 965341116e4..082550533c7 100644 --- a/core/src/components/datetime-button/datetime-button.tsx +++ b/core/src/components/datetime-button/datetime-button.tsx @@ -10,6 +10,7 @@ import type { DatetimePresentation } from '../datetime/datetime-interface'; import { getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; import { getHourCycle } from '../datetime/utils/helpers'; import { parseDate } from '../datetime/utils/parse'; +import { convertDataToISO } from '../datetime/utils/manipulation'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @@ -200,11 +201,14 @@ export class DatetimeButton implements ComponentInterface { const parsedValues = this.getParsedDateValues(value); /** - * Both ion-datetime and ion-datetime-button default - * to today's date and time if no value is set. + * 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 defaultDatetime = [(await datetimeEl.getClosestDate(new Date())).toISOString()]; - const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : defaultDatetime); + const parsedDatetimes = parseDate( + parsedValues.length > 0 ? parsedValues : [convertDataToISO(await datetimeEl.getDefaultPart())] + ); if (!parsedDatetimes) { return; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index d5621e20677..830924ed382 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -40,7 +40,6 @@ import { getLocalizedDateTime, getLocalizedTime, getMonthAndYear, - removeDateTzOffset, } from './utils/format'; import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers'; import { @@ -633,20 +632,15 @@ export class Datetime implements ComponentInterface { } /** - * Get the closest valid Date according to the restrictions on this Datetime - * @param date The Date to find the closest valid value for + * 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 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))); + async getDefaultPart(): Promise { + return this.defaultParts; } private warnIfIncorrectValueUsage = () => { diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index d6b63a168ec..1c78b120d9d 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -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', 'getClosestDate'] + methods: ['confirm', 'reset', 'cancel'] }) @Component({ selector: 'ion-datetime', From 4824122b22af07d1d1f401b7ea83003de68347c9 Mon Sep 17 00:00:00 2001 From: droc101 <37421449+droc101@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:29:27 -0500 Subject: [PATCH 3/4] fix(datetime-button): fix linter errors (npm run lint.fix) --- core/src/components/datetime-button/datetime-button.tsx | 2 +- core/src/components/datetime/datetime.tsx | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index 082550533c7..998ed25e2a0 100644 --- a/core/src/components/datetime-button/datetime-button.tsx +++ b/core/src/components/datetime-button/datetime-button.tsx @@ -9,8 +9,8 @@ import type { Color } from '../../interface'; import type { DatetimePresentation } from '../datetime/datetime-interface'; import { getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; import { getHourCycle } from '../datetime/utils/helpers'; -import { parseDate } from '../datetime/utils/parse'; import { convertDataToISO } from '../datetime/utils/manipulation'; +import { parseDate } from '../datetime/utils/parse'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 830924ed382..b8e2de7ae9b 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -35,12 +35,7 @@ import { getTimeColumnsData, getCombinedDateColumnData, } from './utils/data'; -import { - formatValue, - getLocalizedDateTime, - getLocalizedTime, - getMonthAndYear, -} from './utils/format'; +import { formatValue, getLocalizedDateTime, getLocalizedTime, getMonthAndYear } from './utils/format'; import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers'; import { calculateHourFromAMPM, From ef4c1cd5d35fb706cbb31a4c61c34d69d477b34e Mon Sep 17 00:00:00 2001 From: droc101 <37421449+droc101@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:12:35 -0500 Subject: [PATCH 4/4] test(datetime-button): add tests for datetime constraints - should default to exact current time with no constraints - should obey minuteValues constraint - should obey hourValues constraint - should obey monthValues constraint --- .../test/basic/datetime-button.e2e.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts b/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts index 35088bce4a7..6ab5137598e 100644 --- a/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts @@ -344,4 +344,154 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(page.locator('ion-datetime-button')).toContainText('Thu, November 02 01:22 AM'); }); }); + + test.describe(title('datetime-button: datetime constraints'), () => { + test('should default to exact current time with no constraints', async ({ page }) => { + const fixedTime = new Date('2026-06-18T17:54:54.518Z'); + await page.clock.setFixedTime(fixedTime); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + const dateFormat = new Intl.DateTimeFormat('en-US', { + weekday: 'short', + month: 'long', + day: '2-digit', + }); + const timeFormat = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + + await expect(page.locator('#date-button')).toContainText(dateFormat.format(fixedTime)); + await expect(page.locator('#time-button')).toContainText(timeFormat.format(fixedTime)); + }); + + test('should obey minuteValues constraint', async ({ page }) => { + const fixedTime = new Date('2026-06-18T17:54:54.518Z'); + await page.clock.setFixedTime(fixedTime); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + await page.pause(); + + const expectedTime = fixedTime; + expectedTime.setMinutes(0); + + const timeFormat = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + + await expect(page.locator('#time-button')).toContainText(timeFormat.format(expectedTime)); + }); + + test('should obey hourValues constraint', async ({ page }) => { + const fixedTime = new Date('2026-06-18T17:54:54.518Z'); + await page.clock.setFixedTime(fixedTime); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + await page.pause(); + + const expectedTime = fixedTime; + expectedTime.setHours(0); + + const timeFormat = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + + await expect(page.locator('#time-button')).toContainText(timeFormat.format(expectedTime)); + }); + + test('should obey monthValues constraint', async ({ page }) => { + const fixedTime = new Date('2026-06-18T17:54:54.518Z'); + await page.clock.setFixedTime(fixedTime); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + await page.pause(); + + const expectedTime = fixedTime; + expectedTime.setMonth(0); + + const dateFormat = new Intl.DateTimeFormat('en-US', { + weekday: 'short', + month: 'long', + day: '2-digit', + }); + + await expect(page.locator('#date-button')).toContainText(dateFormat.format(expectedTime)); + }); + }); });