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 () => {

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.

Why is this change necessary?

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

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.

Why is this change necessary?

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 () => {

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.

Why is this change necessary?

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())]
);
Comment on lines +209 to +211

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.

getDefaultPart() already returns DatetimeParts, so wrapping it in convertDataToISO(...) only to feed it back through parseDate is a parts → ISO → parts round-trip. You can read it directly and skip both the conversion and the convertDataToISO import:

Suggested change
const parsedDatetimes = parseDate(
parsedValues.length > 0 ? parsedValues : [convertDataToISO(await datetimeEl.getDefaultPart())]
);
const parsedDatetimes =
parsedValues.length > 0 ? parseDate(parsedValues) : [await datetimeEl.getDefaultPart()];

Unless there's a reason of why you went with this approach?


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'), () => {

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.

Optional / nice-to-have, take it or leave it. These four tests repeat the same clock setup and formatter definitions. We already share per-describe setup with beforeEach elsewhere in this file (see datetime-button.e2e.ts:27 in the "switching to correct view" describe), so we could do the same here. Hoist the constant time and the formatters to the describe scope and set the clock in a beforeEach, then each test only carries its own setContent and expected value:

  test.describe(title('datetime-button: datetime constraints'), () => {
    const fixedTime = new Date('2026-06-18T17:54:54.518Z');

    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',
    });

    test.beforeEach(async ({ page }) => {
      await page.clock.setFixedTime(fixedTime);
    });

Then each test drops its local fixedTime, setFixedTime, and Intl.DateTimeFormat lines and just uses the shared ones. For the expected value, clone before mutating so the shared fixedTime isn't altered between tests:

const expectedTime = new Date(fixedTime);
expectedTime.setMinutes(0);.

One caveat if you do take this: once fixedTime is shared across tests, cloning it before mutating becomes necessary (not just tidy), otherwise a mutation in one test leaks into the others via beforeEach.

test('should default to exact current time with no constraints', async ({ page }) => {

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.

These constraint tests should be annotated with the issue they're covering, like the existing #27797 tests in this file do.

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

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.

Please remove the await page.pause() calls from these tests. page.pause() is a debugging only tool that halts the run and opens the Playwright Inspector, so it shouldn't ship in the suite.


const expectedTime = fixedTime;

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.

expectedTime = fixedTime copies the reference, so setMinutes/setHours/setMonth mutate fixedTime itself. It's harmless here only because fixedTime isn't read again.

Suggested change
const expectedTime = fixedTime;
const expectedTime = new Date(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) {

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.

This helper can go away. It only exists because the earlier getClosestDate(date) method needed reusable logic, but now that getDefaultPart() just returns the already-computed this.defaultParts, this has a single caller (componentWillLoad). I'd revert it back to how it was originally.

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

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.

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({

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.

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