feat(datetime): formatOptions property for Datetime (#29065)

Issue number: Internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->
The Datetime header, Datetime time button, and Datetime Button have
default date formatting that cannot be set by the developer.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- The developer can customize the date and time formatting for the
Datetime header and time button
- The developer can customize the date and time formatting for the
Datetime Button
- A warning will appear in the console if they try to provide a time
zone (the time zone will not get used)
- A warning will be logged if they do not include the date or time
object for formatOptions as needed for the presentation of the Datetime

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

These changes have been reviewed in #29009 and #29059. This PR just adds
them to the feature branch now that the separate tickets are complete.

---------

Co-authored-by: ionitron <hi@ionicframework.com>
Co-authored-by: Liam DeBeasi <liamdebeasi@users.noreply.github.com>
This commit is contained in:
Shawn Taylor
2024-02-22 12:45:47 -05:00
committed by GitHub
parent 7033a28ebd
commit 7cdbc1b5ad
14 changed files with 478 additions and 70 deletions

View File

@ -394,6 +394,7 @@ ion-datetime,prop,dayValues,number | number[] | string | undefined,undefined,fal
ion-datetime,prop,disabled,boolean,false,false,false ion-datetime,prop,disabled,boolean,false,false,false
ion-datetime,prop,doneText,string,'Done',false,false ion-datetime,prop,doneText,string,'Done',false,false
ion-datetime,prop,firstDayOfWeek,number,0,false,false ion-datetime,prop,firstDayOfWeek,number,0,false,false
ion-datetime,prop,formatOptions,undefined | { date: DateTimeFormatOptions; time?: DateTimeFormatOptions | undefined; } | { date?: DateTimeFormatOptions | undefined; time: DateTimeFormatOptions; },undefined,false,false
ion-datetime,prop,highlightedDates,((dateIsoString: string) => DatetimeHighlightStyle | undefined) | DatetimeHighlight[] | undefined,undefined,false,false ion-datetime,prop,highlightedDates,((dateIsoString: string) => DatetimeHighlightStyle | undefined) | DatetimeHighlight[] | undefined,undefined,false,false
ion-datetime,prop,hourCycle,"h11" | "h12" | "h23" | "h24" | undefined,undefined,false,false ion-datetime,prop,hourCycle,"h11" | "h12" | "h23" | "h24" | undefined,undefined,false,false
ion-datetime,prop,hourValues,number | number[] | string | undefined,undefined,false,false ion-datetime,prop,hourValues,number | number[] | string | undefined,undefined,false,false

View File

@ -15,7 +15,7 @@ import { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo
import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
import { SpinnerTypes } from "./components/spinner/spinner-configs"; import { SpinnerTypes } from "./components/spinner/spinner-configs";
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
import { CounterFormatter } from "./components/item/item-interface"; import { CounterFormatter } from "./components/item/item-interface";
@ -51,7 +51,7 @@ export { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo
export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface";
export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface";
export { SpinnerTypes } from "./components/spinner/spinner-configs"; export { SpinnerTypes } from "./components/spinner/spinner-configs";
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
export { CounterFormatter } from "./components/item/item-interface"; export { CounterFormatter } from "./components/item/item-interface";
@ -858,6 +858,10 @@ export namespace Components {
* The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday. * The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday.
*/ */
"firstDayOfWeek": number; "firstDayOfWeek": number;
/**
* 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;
/** /**
* 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"`. * 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"`.
*/ */
@ -5541,6 +5545,10 @@ declare namespace LocalJSX {
* The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday. * The first day of the week to use for `ion-datetime`. The default value is `0` and represents Sunday.
*/ */
"firstDayOfWeek"?: number; "firstDayOfWeek"?: number;
/**
* 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;
/** /**
* 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"`. * 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"`.
*/ */

View File

@ -8,7 +8,7 @@ import { getIonMode } from '../../global/ionic-global';
import type { Color } from '../../interface'; import type { Color } from '../../interface';
import type { DatetimePresentation } from '../datetime/datetime-interface'; import type { DatetimePresentation } from '../datetime/datetime-interface';
import { getToday } from '../datetime/utils/data'; import { getToday } from '../datetime/utils/data';
import { getMonthAndYear, getMonthDayAndYear, getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; import { getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format';
import { getHourCycle } from '../datetime/utils/helpers'; import { getHourCycle } from '../datetime/utils/helpers';
import { parseDate } from '../datetime/utils/parse'; import { parseDate } from '../datetime/utils/parse';
/** /**
@ -196,7 +196,7 @@ export class DatetimeButton implements ComponentInterface {
return; return;
} }
const { value, locale, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl; const { value, locale, formatOptions, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl;
const parsedValues = this.getParsedDateValues(value); const parsedValues = this.getParsedDateValues(value);
@ -225,8 +225,12 @@ export class DatetimeButton implements ComponentInterface {
switch (datetimePresentation) { switch (datetimePresentation) {
case 'date-time': case 'date-time':
case 'time-date': case 'time-date':
const dateText = getMonthDayAndYear(locale, firstParsedDatetime); const dateText = getLocalizedDateTime(
const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle); locale,
firstParsedDatetime,
formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' }
);
const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle, formatOptions?.time);
if (preferWheel) { if (preferWheel) {
this.dateText = `${dateText} ${timeText}`; this.dateText = `${dateText} ${timeText}`;
} else { } else {
@ -246,20 +250,28 @@ export class DatetimeButton implements ComponentInterface {
} }
this.dateText = headerText; this.dateText = headerText;
} else { } else {
this.dateText = getMonthDayAndYear(locale, firstParsedDatetime); this.dateText = getLocalizedDateTime(
locale,
firstParsedDatetime,
formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' }
);
} }
break; break;
case 'time': case 'time':
this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle); this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle, formatOptions?.time);
break; break;
case 'month-year': case 'month-year':
this.dateText = getMonthAndYear(locale, firstParsedDatetime); this.dateText = getLocalizedDateTime(
locale,
firstParsedDatetime,
formatOptions?.date ?? { month: 'long', year: 'numeric' }
);
break; break;
case 'month': case 'month':
this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { month: 'long' }); this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, formatOptions?.time ?? { month: 'long' });
break; break;
case 'year': case 'year':
this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { year: 'numeric' }); this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, formatOptions?.time ?? { year: 'numeric' });
break; break;
} }
}; };

View File

@ -244,4 +244,87 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(page.locator('#time-button')).not.toBeVisible(); await expect(page.locator('#time-button')).not.toBeVisible();
}); });
}); });
test.describe(title('datetime-button: formatOptions'), () => {
test('should include date and time for presentation date-time', async ({ page }) => {
await page.setContent(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date-time" value="2023-11-02T01:22:00" 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();
await expect(page.locator('#date-button')).toContainText('Thu, November 02');
await expect(page.locator('#time-button')).toContainText('01:22 AM');
});
test('should include date for presentation date', async ({ page }) => {
await page.setContent(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date" value="2023-11-02" locale="en-US"></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 expect(page.locator('#date-button')).toContainText('Thu, November 02');
});
test('should include date and time in same button for preferWheel', async ({ page }) => {
await page.setContent(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-datetime id="datetime" presentation="date-time" value="2023-11-02T01:22:00" locale="en-US" prefer-wheel="true"></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();
await expect(page.locator('ion-datetime-button')).toContainText('Thu, November 02 01:22 AM');
});
});
}); });

View File

@ -215,8 +215,41 @@
></ion-datetime> ></ion-datetime>
</ion-popover> </ion-popover>
</div> </div>
<div class="grid-item">
<h2>formatOptions</h2>
<ion-item>
<ion-label>Start Date</ion-label>
<ion-datetime-button datetime="format-options" slot="end"></ion-datetime-button>
</ion-item>
<ion-popover arrow="false">
<ion-datetime
id="format-options"
presentation="date-time"
value="2023-11-02T01:22:00"
locale="en-US"
></ion-datetime>
</ion-popover>
</div>
</div> </div>
</ion-content> </ion-content>
</ion-app> </ion-app>
</body> </body>
<script>
const formatOptionsDatetime = document.querySelector('#format-options');
formatOptionsDatetime.formatOptions = {
date: {
weekday: 'short',
month: 'long',
day: '2-digit',
},
time: {
hour: '2-digit',
minute: '2-digit',
},
};
</script>
</html> </html>

View File

@ -36,3 +36,16 @@ export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle;
export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined; export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined;
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24'; export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';
/**
* FormatOptions must include date and/or time; it cannot be an empty object
*/
export type FormatOptions =
| {
date: Intl.DateTimeFormatOptions;
time?: Intl.DateTimeFormatOptions;
}
| {
date?: Intl.DateTimeFormatOptions;
time: Intl.DateTimeFormatOptions;
};

View File

@ -20,6 +20,7 @@ import type {
DatetimeHighlightStyle, DatetimeHighlightStyle,
DatetimeHighlightCallback, DatetimeHighlightCallback,
DatetimeHourCycle, DatetimeHourCycle,
FormatOptions,
} from './datetime-interface'; } from './datetime-interface';
import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison'; import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison';
import { import {
@ -33,7 +34,7 @@ import {
getTimeColumnsData, getTimeColumnsData,
getCombinedDateColumnData, getCombinedDateColumnData,
} from './utils/data'; } from './utils/data';
import { formatValue, getLocalizedTime, getMonthAndDay, getMonthAndYear } from './utils/format'; import { formatValue, getLocalizedDateTime, getLocalizedTime, getMonthAndYear } from './utils/format';
import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers'; import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers';
import { import {
calculateHourFromAMPM, calculateHourFromAMPM,
@ -68,6 +69,7 @@ import {
isNextMonthDisabled, isNextMonthDisabled,
isPrevMonthDisabled, isPrevMonthDisabled,
} from './utils/state'; } from './utils/state';
import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './utils/validate';
/** /**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
@ -171,6 +173,20 @@ export class Datetime implements ComponentInterface {
*/ */
@Prop() disabled = false; @Prop() disabled = false;
/**
* 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).
*
*/
@Prop() formatOptions?: FormatOptions;
@Watch('formatOptions')
protected formatOptionsChanged() {
const { el, formatOptions, presentation } = this;
checkForPresentationFormatMismatch(el, presentation, formatOptions);
warnIfTimeZoneProvided(el, formatOptions);
}
/** /**
* If `true`, the datetime appears normal but the selected date cannot be changed. * If `true`, the datetime appears normal but the selected date cannot be changed.
*/ */
@ -235,6 +251,12 @@ export class Datetime implements ComponentInterface {
*/ */
@Prop() presentation: DatetimePresentation = 'date-time'; @Prop() presentation: DatetimePresentation = 'date-time';
@Watch('presentation')
protected presentationChanged() {
const { el, formatOptions, presentation } = this;
checkForPresentationFormatMismatch(el, presentation, formatOptions);
}
private get isGridStyle() { private get isGridStyle() {
const { presentation, preferWheel } = this; const { presentation, preferWheel } = this;
const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date';
@ -1357,7 +1379,7 @@ export class Datetime implements ComponentInterface {
}; };
componentWillLoad() { componentWillLoad() {
const { el, highlightedDates, multiple, presentation, preferWheel } = this; const { el, formatOptions, highlightedDates, multiple, presentation, preferWheel } = this;
if (multiple) { if (multiple) {
if (presentation !== 'date') { if (presentation !== 'date') {
@ -1382,6 +1404,11 @@ export class Datetime implements ComponentInterface {
} }
} }
if (formatOptions) {
checkForPresentationFormatMismatch(el, presentation, formatOptions);
warnIfTimeZoneProvided(el, formatOptions);
}
const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues)); const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues));
const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues)); const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues));
const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues)); const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues));
@ -2354,7 +2381,7 @@ export class Datetime implements ComponentInterface {
} }
private renderTimeOverlay() { private renderTimeOverlay() {
const { disabled, hourCycle, isTimePopoverOpen, locale } = this; const { disabled, hourCycle, isTimePopoverOpen, locale, formatOptions } = this;
const computedHourCycle = getHourCycle(locale, hourCycle); const computedHourCycle = getHourCycle(locale, hourCycle);
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
@ -2389,7 +2416,7 @@ export class Datetime implements ComponentInterface {
} }
}} }}
> >
{getLocalizedTime(locale, activePart, computedHourCycle)} {getLocalizedTime(locale, activePart, computedHourCycle, formatOptions?.time)}
</button>, </button>,
<ion-popover <ion-popover
alignment="center" alignment="center"
@ -2424,7 +2451,7 @@ export class Datetime implements ComponentInterface {
} }
private getHeaderSelectedDateText() { private getHeaderSelectedDateText() {
const { activeParts, multiple, titleSelectedDatesFormatter } = this; const { activeParts, formatOptions, multiple, titleSelectedDatesFormatter } = this;
const isArray = Array.isArray(activeParts); const isArray = Array.isArray(activeParts);
let headerText: string; let headerText: string;
@ -2439,7 +2466,11 @@ export class Datetime implements ComponentInterface {
} }
} else { } else {
// for exactly 1 day selected (multiple set or not), show a formatted version of that // for exactly 1 day selected (multiple set or not), show a formatted version of that
headerText = getMonthAndDay(this.locale, this.getActivePartsWithFallback()); headerText = getLocalizedDateTime(
this.locale,
this.getActivePartsWithFallback(),
formatOptions?.date ?? { weekday: 'short', month: 'short', day: 'numeric' }
);
} }
return headerText; return headerText;

View File

@ -565,3 +565,107 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
}); });
}); });
}); });
/**
* This behavior does not differ across
* directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('datetime: formatOptions'), () => {
test('should format header and time button', async ({ page }) => {
await page.setContent(
`
<ion-datetime value="2022-02-01T16:30:00">
<span slot="title">Select Date</span>
</ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
time: { hour: '2-digit', minute: '2-digit' },
date: { day: '2-digit', month: 'long', era: 'short' },
}
</script>
`,
config
);
await page.locator('.datetime-ready').waitFor();
const headerDate = page.locator('ion-datetime .datetime-selected-date');
await expect(headerDate).toHaveText('February 01 AD');
const timeBody = page.locator('ion-datetime .time-body');
await expect(timeBody).toHaveText('04:30 PM');
});
});
});
/**
* This behavior does not differ across
* modes/directions.
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('datetime: formatOptions misconfiguration errors'), () => {
test('should log a warning if time zone is provided', async ({ page }) => {
const logs: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning') {
logs.push(msg.text());
}
});
await page.setContent(
`
<ion-datetime value="2022-02-01T16:30:00" presentation="date">
<span slot="title">Select Date</span>
</ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {
date: { timeZone: 'UTC' },
}
</script>
`,
config
);
await page.locator('.datetime-ready').waitFor();
expect(logs.length).toBe(1);
expect(logs[0]).toContain(
'[Ionic Warning]: Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".'
);
});
test('should log a warning if the required formatOptions are not provided for a presentation', async ({ page }) => {
const logs: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning') {
logs.push(msg.text());
}
});
await page.setContent(
`
<ion-datetime value="2022-02-01T16:30:00">
<span slot="title">Select Date</span>
</ion-datetime>
<script>
const datetime = document.querySelector('ion-datetime');
datetime.formatOptions = {}
</script>
`,
config
);
await page.locator('.datetime-ready').waitFor();
expect(logs.length).toBe(1);
expect(logs[0]).toContain(
"[Ionic Warning]: Datetime: The 'date-time' presentation requires either a date or time object (or both) in formatOptions."
);
});
});
});

View File

@ -308,6 +308,13 @@
</ion-datetime> </ion-datetime>
</ion-modal> </ion-modal>
</div> </div>
<div class="grid-item">
<h2>formatOptions</h2>
<ion-datetime value="2020-03-14T14:23:00.000Z" id="format-options-datetime">
<span slot="title">Select Date</span>
</ion-datetime>
</div>
</div> </div>
</ion-content> </ion-content>
<script> <script>
@ -403,6 +410,12 @@
app.appendChild(modalElement); app.appendChild(modalElement);
return modalElement; return modalElement;
}; };
const formatOptions = document.querySelector('#format-options-datetime');
formatOptions.formatOptions = {
time: { hour: '2-digit', minute: '2-digit' },
date: { day: '2-digit', month: 'long', era: 'short' },
};
</script> </script>
</ion-app> </ion-app>
</body> </body>

View File

@ -1,12 +1,12 @@
import type { DatetimeParts } from '../datetime-interface'; import type { DatetimeParts } from '../datetime-interface';
import { import {
generateDayAriaLabel, generateDayAriaLabel,
getMonthAndDay,
getFormattedHour, getFormattedHour,
addTimePadding, addTimePadding,
getMonthAndYear, getMonthAndYear,
getLocalizedDayPeriod, getLocalizedDayPeriod,
getLocalizedTime, getLocalizedTime,
stripTimeZone,
} from '../utils/format'; } from '../utils/format';
describe('generateDayAriaLabel()', () => { describe('generateDayAriaLabel()', () => {
@ -37,24 +37,6 @@ describe('generateDayAriaLabel()', () => {
}); });
}); });
describe('getMonthAndDay()', () => {
it('should return Tue, May 11', () => {
expect(getMonthAndDay('en-US', { month: 5, day: 11, year: 2021 })).toEqual('Tue, May 11');
});
it('should return mar, 11 may', () => {
expect(getMonthAndDay('es-ES', { month: 5, day: 11, year: 2021 })).toEqual('mar, 11 may');
});
it('should return Sat, Apr 1', () => {
expect(getMonthAndDay('en-US', { month: 4, day: 1, year: 2006 })).toEqual('Sat, Apr 1');
});
it('should return sáb, 1 abr', () => {
expect(getMonthAndDay('es-ES', { month: 4, day: 1, year: 2006 })).toEqual('sáb, 1 abr');
});
});
describe('getFormattedHour()', () => { describe('getFormattedHour()', () => {
it('should only add padding if using 24 hour time', () => { it('should only add padding if using 24 hour time', () => {
expect(getFormattedHour(1, 'h11')).toEqual('1'); expect(getFormattedHour(1, 'h11')).toEqual('1');
@ -144,6 +126,7 @@ describe('getLocalizedTime', () => {
expect(getLocalizedTime('en-GB', datetimeParts, 'h12')).toEqual('12:00 am'); expect(getLocalizedTime('en-GB', datetimeParts, 'h12')).toEqual('12:00 am');
}); });
it('should parse time-only values correctly', () => { it('should parse time-only values correctly', () => {
const datetimeParts: Partial<DatetimeParts> = { const datetimeParts: Partial<DatetimeParts> = {
hour: 22, hour: 22,
@ -153,4 +136,79 @@ describe('getLocalizedTime', () => {
expect(getLocalizedTime('en-US', datetimeParts as DatetimeParts, 'h12')).toEqual('10:40 PM'); expect(getLocalizedTime('en-US', datetimeParts as DatetimeParts, 'h12')).toEqual('10:40 PM');
expect(getLocalizedTime('en-US', datetimeParts as DatetimeParts, 'h23')).toEqual('22:40'); expect(getLocalizedTime('en-US', datetimeParts as DatetimeParts, 'h23')).toEqual('22:40');
}); });
it('should use formatOptions', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 9,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
dayPeriod: 'short',
day: '2-digit',
};
// Even though this method is intended to be used for time, the date may be displayed as well when passing formatOptions
expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('01, 09:40 in the morning');
});
it('should override provided time zone with UTC', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 9,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: 'Australia/Sydney',
timeZoneName: 'long',
hour: 'numeric',
minute: 'numeric',
};
expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('9:40 AM');
});
it('should not include time zone name', () => {
const datetimeParts: DatetimeParts = {
day: 1,
month: 1,
year: 2022,
hour: 9,
minute: 40,
};
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: 'America/Los_Angeles',
timeZoneName: 'long',
hour: 'numeric',
minute: 'numeric',
};
expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('9:40 AM');
});
});
describe('stripTimeZone', () => {
it('should remove the time zone name from the options and set the time zone to UTC', () => {
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: 'America/Los_Angeles',
timeZoneName: 'long',
hour: 'numeric',
minute: 'numeric',
};
expect(stripTimeZone(formatOptions)).toEqual({
timeZone: 'UTC',
hour: 'numeric',
minute: 'numeric',
});
});
}); });

View File

@ -11,7 +11,33 @@ const getFormattedDayPeriod = (dayPeriod?: string) => {
return dayPeriod.toUpperCase(); return dayPeriod.toUpperCase();
}; };
export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCycle: DatetimeHourCycle): string => { /**
* Including time zone options may lead to the rendered text showing a
* different time from what was selected in the Datetime, which could cause
* confusion.
*/
export const stripTimeZone = (formatOptions: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions => {
return {
...formatOptions,
/**
* Setting the time zone to UTC ensures that the value shown is always the
* same as what was selected and safeguards against older Safari bugs with
* Intl.DateTimeFormat.
*/
timeZone: 'UTC',
/**
* We do not want to display the time zone name
*/
timeZoneName: undefined,
};
};
export const getLocalizedTime = (
locale: string,
refParts: DatetimeParts,
hourCycle: DatetimeHourCycle,
formatOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: 'numeric' }
): string => {
const timeParts: Pick<DatetimeParts, 'hour' | 'minute'> = { const timeParts: Pick<DatetimeParts, 'hour' | 'minute'> = {
hour: refParts.hour, hour: refParts.hour,
minute: refParts.minute, minute: refParts.minute,
@ -22,15 +48,7 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCy
} }
return new Intl.DateTimeFormat(locale, { return new Intl.DateTimeFormat(locale, {
hour: 'numeric', ...stripTimeZone(formatOptions),
minute: 'numeric',
/**
* Setting the timeZone to UTC prevents
* new Intl.DatetimeFormat from subtracting
* the user's current timezone offset
* when formatting the time.
*/
timeZone: 'UTC',
/** /**
* We use hourCycle here instead of hour12 due to: * We use hourCycle here instead of hour12 due to:
* https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2 * https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2
@ -146,17 +164,6 @@ export const generateDayAriaLabel = (locale: string, today: boolean, refParts: D
return today ? `Today, ${labelString}` : labelString; return today ? `Today, ${labelString}` : labelString;
}; };
/**
* Gets the day of the week, month, and day
* Used for the header in MD mode.
*/
export const getMonthAndDay = (locale: string, refParts: DatetimeParts) => {
const date = getNormalizedDate(refParts);
return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(
date
);
};
/** /**
* Given a locale and a date object, * Given a locale and a date object,
* return a formatted string that includes * return a formatted string that includes
@ -168,16 +175,6 @@ export const getMonthAndYear = (locale: string, refParts: DatetimeParts) => {
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date); return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date);
}; };
/**
* Given a locale and a date object,
* return a formatted string that includes
* the short month, numeric day, and full year.
* Example: Apr 22, 2021
*/
export const getMonthDayAndYear = (locale: string, refParts: DatetimeParts) => {
return getLocalizedDateTime(locale, refParts, { month: 'short', day: 'numeric', year: 'numeric' });
};
/** /**
* Given a locale and a date object, * Given a locale and a date object,
* return a formatted string that includes * return a formatted string that includes
@ -235,7 +232,7 @@ export const getLocalizedDateTime = (
options: Intl.DateTimeFormatOptions options: Intl.DateTimeFormatOptions
): string => { ): string => {
const date = getNormalizedDate(refParts); const date = getNormalizedDate(refParts);
return getDateTimeFormat(locale, options).format(date); return getDateTimeFormat(locale, stripTimeZone(options)).format(date);
}; };
/** /**

View File

@ -0,0 +1,54 @@
import { printIonWarning } from '@utils/logging';
import type { DatetimePresentation, FormatOptions } from '../datetime-interface';
/**
* If a time zone is provided in the format options, the rendered text could
* differ from what was selected in the Datetime, which could cause
* confusion.
*/
export const warnIfTimeZoneProvided = (el: HTMLElement, formatOptions?: FormatOptions) => {
if (
formatOptions?.date?.timeZone ||
formatOptions?.date?.timeZoneName ||
formatOptions?.time?.timeZone ||
formatOptions?.time?.timeZoneName
) {
printIonWarning('Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".', el);
}
};
export const checkForPresentationFormatMismatch = (
el: HTMLElement,
presentation: DatetimePresentation,
formatOptions?: FormatOptions
) => {
// formatOptions is not required
if (!formatOptions) return;
// If formatOptions is provided, the date and/or time objects are required, depending on the presentation
switch (presentation) {
case 'date':
case 'month-year':
case 'month':
case 'year':
if (formatOptions.date === undefined) {
printIonWarning(`Datetime: The '${presentation}' presentation requires a date object in formatOptions.`, el);
}
break;
case 'time':
if (formatOptions.time === undefined) {
printIonWarning(`Datetime: The 'time' presentation requires a time object in formatOptions.`, el);
}
break;
case 'date-time':
case 'time-date':
if (formatOptions.date === undefined && formatOptions.time === undefined) {
printIonWarning(
`Datetime: The '${presentation}' presentation requires either a date or time object (or both) in formatOptions.`,
el
);
}
break;
}
};

View File

@ -635,7 +635,7 @@ Set `scrollEvents` to `true` to enable.
@ProxyCmp({ @ProxyCmp({
inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'], inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
methods: ['confirm', 'reset', 'cancel'] methods: ['confirm', 'reset', 'cancel']
}) })
@Component({ @Component({
@ -643,7 +643,7 @@ Set `scrollEvents` to `true` to enable.
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>', template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'], inputs: ['cancelText', 'clearText', 'color', 'dayValues', 'disabled', 'doneText', 'firstDayOfWeek', 'formatOptions', 'highlightedDates', 'hourCycle', 'hourValues', 'isDateEnabled', 'locale', 'max', 'min', 'minuteValues', 'mode', 'monthValues', 'multiple', 'name', 'preferWheel', 'presentation', 'readonly', 'showClearButton', 'showDefaultButtons', 'showDefaultTimeLabel', 'showDefaultTitle', 'size', 'titleSelectedDatesFormatter', 'value', 'yearValues'],
}) })
export class IonDatetime { export class IonDatetime {
protected el: HTMLElement; protected el: HTMLElement;

View File

@ -274,6 +274,7 @@ export const IonDatetime = /*@__PURE__*/ defineContainer<JSX.IonDatetime, JSX.Io
'color', 'color',
'name', 'name',
'disabled', 'disabled',
'formatOptions',
'readonly', 'readonly',
'isDateEnabled', 'isDateEnabled',
'min', 'min',