Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 6 additions & 2 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -961,6 +961,10 @@ 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;
/**
* 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).
*/
"getDefaultPart": () => Promise<DatetimeParts>;
/**
* 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
18 changes: 11 additions & 7 deletions core/src/components/datetime-button/datetime-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ 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 { convertDataToISO } from '../datetime/utils/manipulation';
import { parseDate } from '../datetime/utils/parse';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
Expand Down Expand Up @@ -125,7 +125,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 +138,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 +189,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 @@ -201,10 +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 parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]);
const parsedDatetimes = parseDate(
parsedValues.length > 0 ? parsedValues : [convertDataToISO(await datetimeEl.getDefaultPart())]
);

if (!parsedDatetimes) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date-time" locale="en-US"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
date: {
weekday: "short",
month: "long",
day: "2-digit"
},
time: {
hour: "2-digit",
minute: "2-digit"
}
}
</script>
`,
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(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="time" locale="en-US" minute-values="0"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
time: {
hour: "2-digit",
minute: "2-digit"
}
}
</script>
`,
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(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="time" locale="en-US" hour-values="0"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
time: {
hour: "2-digit",
minute: "2-digit"
}
}
</script>
`,
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(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date" locale="en-US" month-values="1"></ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
date: {
weekday: "short",
month: "long",
day: "2-digit"
}
}
</script>
`,
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));
});
});
});
51 changes: 35 additions & 16 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,40 @@ 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,
});
}

/**
* 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;
}
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 +1529,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
Loading