From 7ac08bca1e252a12a1c3d2cdc1a298bba1f8963d Mon Sep 17 00:00:00 2001 From: Shawn Taylor Date: Thu, 15 Feb 2024 09:55:06 -0500 Subject: [PATCH 1/5] feat(datetime): formatOptions for time button and header (#29009) Issue number: Internal --------- ## What is the current behavior? The Datetime header and time button have default date formatting that cannot be set by the developer. ## What is the new behavior? - The developer can customize the date and time formatting for the Datetime header and time button - A warning will appear in the console if they try to provide a time zone (the time zone will not get used) ## Does this introduce a breaking change? - [ ] Yes - [x] No --------- Co-authored-by: ionitron --- core/api.txt | 1 + core/src/components.d.ts | 12 +- .../components/datetime/datetime-interface.ts | 13 +++ core/src/components/datetime/datetime.tsx | 43 +++++++- .../datetime/test/basic/datetime.e2e.ts | 104 ++++++++++++++++++ .../components/datetime/test/format.spec.ts | 96 ++++++++++++---- core/src/components/datetime/utils/format.ts | 49 +++++---- core/src/components/datetime/utils/warn.ts | 52 +++++++++ packages/angular/src/directives/proxies.ts | 4 +- packages/vue/src/proxies.ts | 1 + 10 files changed, 325 insertions(+), 50 deletions(-) create mode 100644 core/src/components/datetime/utils/warn.ts diff --git a/core/api.txt b/core/api.txt index fa3825d2903..acdcb8d0faf 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; 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,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 a706eb8971d..ab938b5bc16 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, 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 { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-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 { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-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 { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-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. */ "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"`. */ @@ -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 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"`. */ diff --git a/core/src/components/datetime/datetime-interface.ts b/core/src/components/datetime/datetime-interface.ts index 255f39e22dc..475a672d069 100644 --- a/core/src/components/datetime/datetime-interface.ts +++ b/core/src/components/datetime/datetime-interface.ts @@ -36,3 +36,16 @@ export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle; export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined; 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; + }; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 57f275e25f0..9ddd1cb4ecb 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -20,6 +20,7 @@ import type { DatetimeHighlightStyle, DatetimeHighlightCallback, DatetimeHourCycle, + FormatOptions, } from './datetime-interface'; import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison'; import { @@ -33,7 +34,7 @@ import { getTimeColumnsData, getCombinedDateColumnData, } 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 { calculateHourFromAMPM, @@ -68,6 +69,7 @@ import { isNextMonthDisabled, isPrevMonthDisabled, } from './utils/state'; +import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './utils/warn'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. @@ -171,6 +173,20 @@ export class Datetime implements ComponentInterface { */ @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 { formatOptions, presentation } = this; + checkForPresentationFormatMismatch(presentation, formatOptions); + warnIfTimeZoneProvided(formatOptions); + } + /** * 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'; + @Watch('presentation') + protected presentationChanged() { + const { formatOptions, presentation } = this; + checkForPresentationFormatMismatch(presentation, formatOptions); + } + private get isGridStyle() { const { presentation, preferWheel } = this; const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; @@ -1357,7 +1379,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 +1404,11 @@ export class Datetime implements ComponentInterface { } } + if (formatOptions) { + checkForPresentationFormatMismatch(presentation, formatOptions); + warnIfTimeZoneProvided(formatOptions); + } + const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues)); const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues)); const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues)); @@ -2354,7 +2381,7 @@ export class Datetime implements ComponentInterface { } private renderTimeOverlay() { - const { disabled, hourCycle, isTimePopoverOpen, locale } = this; + const { disabled, hourCycle, isTimePopoverOpen, locale, formatOptions } = this; const computedHourCycle = getHourCycle(locale, hourCycle); const activePart = this.getActivePartsWithFallback(); @@ -2389,7 +2416,7 @@ export class Datetime implements ComponentInterface { } }} > - {getLocalizedTime(locale, activePart, computedHourCycle)} + {getLocalizedTime(locale, activePart, computedHourCycle, formatOptions?.time)} , { }); }); }); + +/** + * 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( + ` + + Select Date + + + `, + 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( + ` + + Select Date + + + `, + 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( + ` + + Select Date + + + `, + 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." + ); + }); + }); +}); diff --git a/core/src/components/datetime/test/format.spec.ts b/core/src/components/datetime/test/format.spec.ts index 5ff218167d4..7161a1aeae3 100644 --- a/core/src/components/datetime/test/format.spec.ts +++ b/core/src/components/datetime/test/format.spec.ts @@ -1,12 +1,12 @@ import type { DatetimeParts } from '../datetime-interface'; import { generateDayAriaLabel, - getMonthAndDay, getFormattedHour, addTimePadding, getMonthAndYear, getLocalizedDayPeriod, getLocalizedTime, + stripTimeZone, } from '../utils/format'; 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()', () => { it('should only add padding if using 24 hour time', () => { expect(getFormattedHour(1, 'h11')).toEqual('1'); @@ -144,6 +126,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 +136,79 @@ 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', + 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', + }); + }); }); diff --git a/core/src/components/datetime/utils/format.ts b/core/src/components/datetime/utils/format.ts index 0f70299dc52..1b498512c64 100644 --- a/core/src/components/datetime/utils/format.ts +++ b/core/src/components/datetime/utils/format.ts @@ -11,7 +11,33 @@ const getFormattedDayPeriod = (dayPeriod?: string) => { 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 => { + /** + * We do not want to display the time zone name + */ + delete formatOptions.timeZoneName; + + /** + * 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. + */ + formatOptions.timeZone = 'UTC'; + + return formatOptions; +}; + +export const getLocalizedTime = ( + locale: string, + refParts: DatetimeParts, + hourCycle: DatetimeHourCycle, + formatOptions: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: 'numeric' } +): string => { const timeParts: Pick = { hour: refParts.hour, minute: refParts.minute, @@ -22,15 +48,7 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCy } return new Intl.DateTimeFormat(locale, { - hour: 'numeric', - 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', + ...stripTimeZone(formatOptions), /** * We use hourCycle here instead of hour12 due to: * 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; }; -/** - * 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, * return a formatted string that includes diff --git a/core/src/components/datetime/utils/warn.ts b/core/src/components/datetime/utils/warn.ts new file mode 100644 index 00000000000..0b940d26c00 --- /dev/null +++ b/core/src/components/datetime/utils/warn.ts @@ -0,0 +1,52 @@ +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 = (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".'); + } +}; + +export const checkForPresentationFormatMismatch = ( + 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.`); + } + break; + case 'time': + if (formatOptions.time === undefined) { + printIonWarning(`Datetime: The 'time' presentation requires a time object in formatOptions.`); + } + 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.` + ); + } + break; + } +}; diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index be168ed8703..a592c55894b 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 1ea101fb239..57380e47bde 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -274,6 +274,7 @@ export const IonDatetime = /*@__PURE__*/ defineContainer Date: Thu, 15 Feb 2024 17:07:31 -0500 Subject: [PATCH 2/5] feat(datetime-button): formatOptions for Datetime Button (#29059) Issue number: Internal --------- ## What is the current behavior? The Datetime Button has default date formatting that cannot be set by the developer. ## What is the new behavior? - The developer can customize the date and time formatting for the Datetime Button - 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 --------- Co-authored-by: Liam DeBeasi --- .../datetime-button/datetime-button.tsx | 30 +++++-- .../test/basic/datetime-button.e2e.ts | 83 +++++++++++++++++++ core/src/components/datetime/utils/format.ts | 12 +-- 3 files changed, 105 insertions(+), 20 deletions(-) diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index 7ce3ca4163d..519fb42da6a 100644 --- a/core/src/components/datetime-button/datetime-button.tsx +++ b/core/src/components/datetime-button/datetime-button.tsx @@ -8,7 +8,7 @@ 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 { getMonthAndYear, getMonthDayAndYear, getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; +import { getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; import { getHourCycle } from '../datetime/utils/helpers'; import { parseDate } from '../datetime/utils/parse'; /** @@ -196,7 +196,7 @@ export class DatetimeButton implements ComponentInterface { return; } - const { value, locale, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl; + const { value, locale, formatOptions, hourCycle, preferWheel, multiple, titleSelectedDatesFormatter } = datetimeEl; const parsedValues = this.getParsedDateValues(value); @@ -225,8 +225,12 @@ export class DatetimeButton implements ComponentInterface { switch (datetimePresentation) { case 'date-time': case 'time-date': - const dateText = getMonthDayAndYear(locale, firstParsedDatetime); - const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle); + const dateText = getLocalizedDateTime( + locale, + firstParsedDatetime, + formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' } + ); + const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle, formatOptions?.time); if (preferWheel) { this.dateText = `${dateText} ${timeText}`; } else { @@ -246,20 +250,28 @@ export class DatetimeButton implements ComponentInterface { } this.dateText = headerText; } else { - this.dateText = getMonthDayAndYear(locale, firstParsedDatetime); + this.dateText = getLocalizedDateTime( + locale, + firstParsedDatetime, + formatOptions?.date ?? { month: 'short', day: 'numeric', year: 'numeric' } + ); } break; case 'time': - this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle); + this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle, formatOptions?.time); break; case 'month-year': - this.dateText = getMonthAndYear(locale, firstParsedDatetime); + this.dateText = getLocalizedDateTime( + locale, + firstParsedDatetime, + formatOptions?.date ?? { month: 'long', year: 'numeric' } + ); break; case 'month': - this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { month: 'long' }); + this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, formatOptions?.time ?? { month: 'long' }); break; case 'year': - this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, { year: 'numeric' }); + this.dateText = getLocalizedDateTime(locale, firstParsedDatetime, formatOptions?.time ?? { year: 'numeric' }); break; } }; 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 938ce976e54..7024527de25 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 @@ -244,4 +244,87 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { 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( + ` + + + + `, + 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( + ` + + + + `, + 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( + ` + + + + `, + config + ); + + await page.locator('.datetime-ready').waitFor(); + + await expect(page.locator('ion-datetime-button')).toContainText('Thu, November 02 01:22 AM'); + }); + }); }); diff --git a/core/src/components/datetime/utils/format.ts b/core/src/components/datetime/utils/format.ts index 1b498512c64..3d9f491ff0e 100644 --- a/core/src/components/datetime/utils/format.ts +++ b/core/src/components/datetime/utils/format.ts @@ -175,16 +175,6 @@ export const getMonthAndYear = (locale: string, refParts: DatetimeParts) => { 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, * return a formatted string that includes @@ -242,7 +232,7 @@ export const getLocalizedDateTime = ( options: Intl.DateTimeFormatOptions ): string => { const date = getNormalizedDate(refParts); - return getDateTimeFormat(locale, options).format(date); + return getDateTimeFormat(locale, stripTimeZone(options)).format(date); }; /** From d9239e1b5646daf43fc2e2d7555ef8b5f4e2c532 Mon Sep 17 00:00:00 2001 From: Shawn Taylor Date: Tue, 20 Feb 2024 09:21:53 -0500 Subject: [PATCH 3/5] refactor --- core/src/components/datetime/utils/format.ts | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/core/src/components/datetime/utils/format.ts b/core/src/components/datetime/utils/format.ts index 3d9f491ff0e..6443e1b5465 100644 --- a/core/src/components/datetime/utils/format.ts +++ b/core/src/components/datetime/utils/format.ts @@ -17,19 +17,19 @@ const getFormattedDayPeriod = (dayPeriod?: string) => { * confusion. */ export const stripTimeZone = (formatOptions: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions => { - /** - * We do not want to display the time zone name - */ - delete formatOptions.timeZoneName; - - /** - * 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. - */ - formatOptions.timeZone = 'UTC'; - - return formatOptions; + 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 = ( From 85a7d7376313ea3b7d0aca3b04d9f5127ebc7f04 Mon Sep 17 00:00:00 2001 From: Shawn Taylor Date: Wed, 21 Feb 2024 13:10:00 -0500 Subject: [PATCH 4/5] Improve validation --- core/src/components/datetime/datetime.tsx | 16 ++++++++-------- .../datetime/utils/{warn.ts => validate.ts} | 12 +++++++----- 2 files changed, 15 insertions(+), 13 deletions(-) rename core/src/components/datetime/utils/{warn.ts => validate.ts} (82%) diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 9ddd1cb4ecb..f6a827d8e5d 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -69,7 +69,7 @@ import { isNextMonthDisabled, isPrevMonthDisabled, } from './utils/state'; -import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './utils/warn'; +import { checkForPresentationFormatMismatch, warnIfTimeZoneProvided } from './utils/validate'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. @@ -182,9 +182,9 @@ export class Datetime implements ComponentInterface { @Watch('formatOptions') protected formatOptionsChanged() { - const { formatOptions, presentation } = this; - checkForPresentationFormatMismatch(presentation, formatOptions); - warnIfTimeZoneProvided(formatOptions); + const { el, formatOptions, presentation } = this; + checkForPresentationFormatMismatch(el, presentation, formatOptions); + warnIfTimeZoneProvided(el, formatOptions); } /** @@ -253,8 +253,8 @@ export class Datetime implements ComponentInterface { @Watch('presentation') protected presentationChanged() { - const { formatOptions, presentation } = this; - checkForPresentationFormatMismatch(presentation, formatOptions); + const { el, formatOptions, presentation } = this; + checkForPresentationFormatMismatch(el, presentation, formatOptions); } private get isGridStyle() { @@ -1405,8 +1405,8 @@ export class Datetime implements ComponentInterface { } if (formatOptions) { - checkForPresentationFormatMismatch(presentation, formatOptions); - warnIfTimeZoneProvided(formatOptions); + checkForPresentationFormatMismatch(el, presentation, formatOptions); + warnIfTimeZoneProvided(el, formatOptions); } const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues)); diff --git a/core/src/components/datetime/utils/warn.ts b/core/src/components/datetime/utils/validate.ts similarity index 82% rename from core/src/components/datetime/utils/warn.ts rename to core/src/components/datetime/utils/validate.ts index 0b940d26c00..46c3fe633b8 100644 --- a/core/src/components/datetime/utils/warn.ts +++ b/core/src/components/datetime/utils/validate.ts @@ -7,18 +7,19 @@ import type { DatetimePresentation, FormatOptions } from '../datetime-interface' * differ from what was selected in the Datetime, which could cause * confusion. */ -export const warnIfTimeZoneProvided = (formatOptions?: FormatOptions) => { +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".'); + printIonWarning('Datetime: "timeZone" and "timeZoneName" are not supported in "formatOptions".', el); } }; export const checkForPresentationFormatMismatch = ( + el: HTMLElement, presentation: DatetimePresentation, formatOptions?: FormatOptions ) => { @@ -32,19 +33,20 @@ export const checkForPresentationFormatMismatch = ( case 'month': case 'year': if (formatOptions.date === undefined) { - printIonWarning(`Datetime: The '${presentation}' presentation requires a date object in formatOptions.`); + 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.`); + 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.` + `Datetime: The '${presentation}' presentation requires either a date or time object (or both) in formatOptions.`, + el ); } break; From 016b60a7c2be84419c9e25f29261957b431f842d Mon Sep 17 00:00:00 2001 From: Shawn Taylor Date: Wed, 21 Feb 2024 13:19:00 -0500 Subject: [PATCH 5/5] Add examples for formatOptions --- .../datetime-button/test/basic/index.html | 33 +++++++++++++++++++ .../components/datetime/test/basic/index.html | 13 ++++++++ 2 files changed, 46 insertions(+) diff --git a/core/src/components/datetime-button/test/basic/index.html b/core/src/components/datetime-button/test/basic/index.html index c446544a0a3..dd269d984ed 100644 --- a/core/src/components/datetime-button/test/basic/index.html +++ b/core/src/components/datetime-button/test/basic/index.html @@ -215,8 +215,41 @@

preferWheel / date-time

>
+ +
+

formatOptions

+ + + Start Date + + + + + + +
+ + diff --git a/core/src/components/datetime/test/basic/index.html b/core/src/components/datetime/test/basic/index.html index 2023b4a1190..b3afa766887 100644 --- a/core/src/components/datetime/test/basic/index.html +++ b/core/src/components/datetime/test/basic/index.html @@ -308,6 +308,13 @@

Modal - Custom

+ +
+

formatOptions

+ + Select Date + +