diff --git a/core/api.txt b/core/api.txt index fa3825d290..e256b31222 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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,doneText,string,'Done',false,false ion-datetime,prop,firstDayOfWeek,number,0,false,false +ion-datetime,prop,formatOptions,undefined | { date?: DateTimeFormatOptions | undefined; time?: DateTimeFormatOptions | 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,hourValues,number | number[] | string | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index bcea90eeb2..efbc4bcebe 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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. */ "firstDayOfWeek": number; + /** + * Formatting options, separated by date and time. + */ + "formatOptions"?: { date?: Intl.DateTimeFormatOptions; time?: Intl.DateTimeFormatOptions }; /** * 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. */ "firstDayOfWeek"?: number; + /** + * Formatting options, separated by date and time. + */ + "formatOptions"?: { date?: Intl.DateTimeFormatOptions; time?: Intl.DateTimeFormatOptions }; /** * 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/datetime.tsx b/core/src/components/datetime/datetime.tsx index 57f275e25f..b9c1afe6fa 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -171,6 +171,19 @@ export class Datetime implements ComponentInterface { */ @Prop() disabled = false; + /** + * Formatting options, separated by date and time. + */ + @Prop() formatOptions?: { date?: Intl.DateTimeFormatOptions; time?: Intl.DateTimeFormatOptions }; + + @Watch('formatOptions') + protected formatOptionsChanged(formatOptions: { + date?: Intl.DateTimeFormatOptions; + time?: Intl.DateTimeFormatOptions; + }) { + this.errorIfTimeZoneProvided(formatOptions); + } + /** * If `true`, the datetime appears normal but the selected date cannot be changed. */ @@ -1357,7 +1370,7 @@ export class Datetime implements ComponentInterface { }; componentWillLoad() { - const { el, highlightedDates, multiple, presentation, preferWheel } = this; + const { el, formatOptions, highlightedDates, multiple, presentation, preferWheel } = this; if (multiple) { if (presentation !== 'date') { @@ -1382,6 +1395,10 @@ export class Datetime implements ComponentInterface { } } + if (formatOptions) { + this.errorIfTimeZoneProvided(formatOptions); + } + const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues)); const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues)); const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues)); @@ -1409,6 +1426,20 @@ export class Datetime implements ComponentInterface { this.emitStyle(); } + private errorIfTimeZoneProvided(formatOptions: { + date?: Intl.DateTimeFormatOptions; + time?: Intl.DateTimeFormatOptions; + }) { + if ( + formatOptions?.date?.timeZone || + formatOptions?.time?.timeZone || + formatOptions?.date?.timeZoneName || + formatOptions?.time?.timeZoneName + ) { + printIonWarning('Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".'); + } + } + private emitStyle() { this.ionStyle.emit({ interactive: true, @@ -2354,10 +2385,16 @@ export class Datetime implements ComponentInterface { } private renderTimeOverlay() { - const { disabled, hourCycle, isTimePopoverOpen, locale } = this; + const { disabled, formatOptions, hourCycle, isTimePopoverOpen, locale } = this; const computedHourCycle = getHourCycle(locale, hourCycle); const activePart = this.getActivePartsWithFallback(); + const timeButtonFormatOptions = formatOptions?.time || { + hour: 'numeric', + minute: 'numeric', + computedHourCycle, + }; + return [
{this.renderTimeLabel()}
, , { }); }); }); + +/** + * This behavior does not differ across + * directions. + */ +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('datetime: formatOptions'), () => { + test('should format header and time button', async ({ page }) => { + await page.setContent( + ` + + Select Date + + `, + config + ); + + await page.locator('.datetime-ready').waitFor(); + + const datetime = page.locator('ion-datetime'); + + await datetime.evaluate( + (el: HTMLIonDatetimeElement) => + (el.formatOptions = { + time: { hour: '2-digit', minute: '2-digit' }, + date: { day: '2-digit', month: 'long', era: 'short' }, + }) + ); + + await page.waitForChanges(); + + 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'); + + await expect(datetime).toHaveScreenshot(screenshot('datetime-format-options')); + }); + }); +}); + +/** + * This behavior does not differ across + * modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('datetime: formatOptions timeZone error'), () => { + test('should throw 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( + ` + + Select Date + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + + await datetime.evaluate( + (el: HTMLIonDatetimeElement) => + (el.formatOptions = { + time: { timeZone: 'UTC' }, + }) + ); + + await page.locator('.datetime-ready').waitFor(); + + await page.waitForChanges(); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain( + '[Ionic Warning]: Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".' + ); + }); + }); +}); diff --git a/core/src/components/datetime/test/format.spec.ts b/core/src/components/datetime/test/format.spec.ts index 5ff218167d..7b595f687c 100644 --- a/core/src/components/datetime/test/format.spec.ts +++ b/core/src/components/datetime/test/format.spec.ts @@ -53,6 +53,46 @@ describe('getMonthAndDay()', () => { it('should return sáb, 1 abr', () => { expect(getMonthAndDay('es-ES', { month: 4, day: 1, year: 2006 })).toEqual('sáb, 1 abr'); }); + + it('should use formatOptions', () => { + const datetimeParts: DatetimeParts = { + day: 1, + month: 1, + year: 2022, + hour: 9, + minute: 40, + }; + + const formatOptions: Intl.DateTimeFormatOptions = { + day: '2-digit', + weekday: 'long', + month: 'narrow', + hour: '2-digit', + minute: '2-digit', + }; + + // Even though this method is intended to be used for date, the time may be displayed as well when passing formatOptions + expect(getMonthAndDay('en-US', datetimeParts, formatOptions)).toEqual('Saturday, J 01, 09:40 AM'); + }); + + it('should override provided time zone with UTC', () => { + const datetimeParts: DatetimeParts = { + day: 1, + month: 1, + year: 2022, + hour: 23, + minute: 40, + }; + + const formatOptions: Intl.DateTimeFormatOptions = { + timeZone: 'Australia/Sydney', + weekday: 'short', + month: 'short', + day: 'numeric', + }; + + expect(getMonthAndDay('en-US', datetimeParts, formatOptions)).toEqual('Sat, Jan 1'); + }); }); describe('getFormattedHour()', () => { @@ -144,6 +184,7 @@ describe('getLocalizedTime', () => { expect(getLocalizedTime('en-GB', datetimeParts, 'h12')).toEqual('12:00 am'); }); + it('should parse time-only values correctly', () => { const datetimeParts: Partial = { hour: 22, @@ -153,4 +194,42 @@ describe('getLocalizedTime', () => { expect(getLocalizedTime('en-US', datetimeParts as DatetimeParts, 'h12')).toEqual('10:40 PM'); 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', + hour: 'numeric', + minute: 'numeric', + }; + + expect(getLocalizedTime('en-US', datetimeParts, 'h12', formatOptions)).toEqual('9:40 AM'); + }); }); diff --git a/core/src/components/datetime/utils/format.ts b/core/src/components/datetime/utils/format.ts index 0f70299dc5..6589c10e40 100644 --- a/core/src/components/datetime/utils/format.ts +++ b/core/src/components/datetime/utils/format.ts @@ -11,7 +11,12 @@ const getFormattedDayPeriod = (dayPeriod?: string) => { return dayPeriod.toUpperCase(); }; -export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCycle: DatetimeHourCycle): string => { +export const getLocalizedTime = ( + locale: string, + refParts: DatetimeParts, + hourCycle: DatetimeHourCycle, + formatOptions?: Intl.DateTimeFormatOptions +): string => { const timeParts: Pick = { hour: refParts.hour, minute: refParts.minute, @@ -21,9 +26,18 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCy return 'Invalid Time'; } + const defaultFormatOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: 'numeric' }; + + // If any options are provided, don't use any of the defaults. + const options: Intl.DateTimeFormatOptions = formatOptions ?? defaultFormatOptions; + return new Intl.DateTimeFormat(locale, { - hour: 'numeric', - minute: 'numeric', + ...options, + /** + * We use hourCycle here instead of hour12 due to: + * https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2 + */ + hourCycle, /** * Setting the timeZone to UTC prevents * new Intl.DatetimeFormat from subtracting @@ -31,11 +45,6 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCy * when formatting the time. */ timeZone: 'UTC', - /** - * We use hourCycle here instead of hour12 due to: - * https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2 - */ - hourCycle, /** * Setting Z at the end indicates that this * date string is in the UTC time zone. This @@ -150,11 +159,17 @@ export const generateDayAriaLabel = (locale: string, today: boolean, refParts: D * Gets the day of the week, month, and day * Used for the header in MD mode. */ -export const getMonthAndDay = (locale: string, refParts: DatetimeParts) => { +export const getMonthAndDay = (locale: string, refParts: DatetimeParts, formatOptions?: Intl.DateTimeFormatOptions) => { + const defaultFormatOptions: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' }; + + // If any options are provided, don't use any of the defaults. This way the developer can (for example) choose to not have the weekday displayed at all. + const options: Intl.DateTimeFormatOptions = formatOptions ?? defaultFormatOptions; + const date = getNormalizedDate(refParts); - return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format( - date - ); + return new Intl.DateTimeFormat(locale, { + ...options, + timeZone: 'UTC', + }).format(date); }; /** diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index be168ed870..a592c55894 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', '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'] }) @Component({ @@ -643,7 +643,7 @@ Set `scrollEvents` to `true` to enable. changeDetection: ChangeDetectionStrategy.OnPush, template: '', // 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 { protected el: HTMLElement; diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 1ea101fb23..57380e47bd 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -274,6 +274,7 @@ export const IonDatetime = /*@__PURE__*/ defineContainer