From 7cdbc1b5ad004e17a7c51363653e0e67f50e6860 Mon Sep 17 00:00:00 2001 From: Shawn Taylor Date: Thu, 22 Feb 2024 12:45:47 -0500 Subject: [PATCH 1/2] feat(datetime): formatOptions property for Datetime (#29065) Issue number: Internal --------- ## What is the current behavior? 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? - 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 ## Other information 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 Co-authored-by: Liam DeBeasi --- core/api.txt | 1 + core/src/components.d.ts | 12 +- .../datetime-button/datetime-button.tsx | 30 +++-- .../test/basic/datetime-button.e2e.ts | 83 ++++++++++++++ .../datetime-button/test/basic/index.html | 33 ++++++ .../components/datetime/datetime-interface.ts | 13 +++ core/src/components/datetime/datetime.tsx | 43 +++++++- .../datetime/test/basic/datetime.e2e.ts | 104 ++++++++++++++++++ .../components/datetime/test/basic/index.html | 13 +++ .../components/datetime/test/format.spec.ts | 96 ++++++++++++---- core/src/components/datetime/utils/format.ts | 61 +++++----- .../src/components/datetime/utils/validate.ts | 54 +++++++++ packages/angular/src/directives/proxies.ts | 4 +- packages/vue/src/proxies.ts | 1 + 14 files changed, 478 insertions(+), 70 deletions(-) create mode 100644 core/src/components/datetime/utils/validate.ts diff --git a/core/api.txt b/core/api.txt index fa3825d290..acdcb8d0fa 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 a706eb8971..ab938b5bc1 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-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index 7ce3ca4163..519fb42da6 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 938ce976e5..7024527de2 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-button/test/basic/index.html b/core/src/components/datetime-button/test/basic/index.html index c446544a0a..dd269d984e 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 @@ > + +
+

formatOptions

+ + + Start Date + + + + + + +
+ + diff --git a/core/src/components/datetime/datetime-interface.ts b/core/src/components/datetime/datetime-interface.ts index 255f39e22d..475a672d06 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 57f275e25f..f6a827d8e5 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/validate'; /** * @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 { el, formatOptions, presentation } = this; + checkForPresentationFormatMismatch(el, presentation, formatOptions); + warnIfTimeZoneProvided(el, 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 { el, formatOptions, presentation } = this; + checkForPresentationFormatMismatch(el, 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(el, presentation, formatOptions); + warnIfTimeZoneProvided(el, 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/basic/index.html b/core/src/components/datetime/test/basic/index.html index 2023b4a119..b3afa76688 100644 --- a/core/src/components/datetime/test/basic/index.html +++ b/core/src/components/datetime/test/basic/index.html @@ -308,6 +308,13 @@ + +
+

formatOptions

+ + Select Date + +
diff --git a/core/src/components/datetime/test/format.spec.ts b/core/src/components/datetime/test/format.spec.ts index 5ff218167d..7161a1aeae 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 0f70299dc5..6443e1b546 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 => { + 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 = { 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 @@ -168,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 @@ -235,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); }; /** diff --git a/core/src/components/datetime/utils/validate.ts b/core/src/components/datetime/utils/validate.ts new file mode 100644 index 0000000000..46c3fe633b --- /dev/null +++ b/core/src/components/datetime/utils/validate.ts @@ -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; + } +}; 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 Date: Fri, 1 Mar 2024 17:12:05 -0500 Subject: [PATCH 2/2] feat(searchbar): autocapitalize, dir, lang, maxlength, and minlength are inherited to native input (#29098) Issue number: resolves #27606 --------- ## What is the current behavior? Certain attributes are not be inherited to the inner searchbar. Developers need control over these attributes to provide important context to users for things like language and text direction. Additionally, being able to control things like autocapitalize, maxlength, and minlength can help improve the user experience by a) guiding what should be entered into an input and b) removing autocapitalize where it's not appropriate. ## What is the new behavior? - Added autocapitalize, maxlength, and minlength properties - lang and dir are global attributes, so adding them as properties will cause issues. However, developers can still set them as attributes and they will be inherited to the native `input` element. We also watch them so any changes to the attributes are also inherited to the native `input`. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Note: We expanded the scope of this work to also include input and textarea, and this work will be handled separately. However, the original request was only for searchbar so that's why I associated this PR with the linked issue. Dev build: `7.7.3-dev.11709159644.114cd8b1` --- core/api.txt | 3 + core/src/components.d.ts | 24 +++++++ core/src/components/searchbar/searchbar.tsx | 70 ++++++++++++++++++- .../searchbar/test/searchbar.spec.ts | 28 +++++++- packages/angular/src/directives/proxies.ts | 4 +- packages/vue/src/proxies.ts | 3 + 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/core/api.txt b/core/api.txt index acdcb8d0fa..498263defe 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1158,6 +1158,7 @@ ion-row,shadow ion-searchbar,scoped ion-searchbar,prop,animated,boolean,false,false,false +ion-searchbar,prop,autocapitalize,string,undefined,true,false ion-searchbar,prop,autocomplete,"name" | "email" | "tel" | "url" | "on" | "off" | "honorific-prefix" | "given-name" | "additional-name" | "family-name" | "honorific-suffix" | "nickname" | "username" | "new-password" | "current-password" | "one-time-code" | "organization-title" | "organization" | "street-address" | "address-line1" | "address-line2" | "address-line3" | "address-level4" | "address-level3" | "address-level2" | "address-level1" | "country" | "country-name" | "postal-code" | "cc-name" | "cc-given-name" | "cc-additional-name" | "cc-family-name" | "cc-number" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-csc" | "cc-type" | "transaction-currency" | "transaction-amount" | "language" | "bday" | "bday-day" | "bday-month" | "bday-year" | "sex" | "tel-country-code" | "tel-national" | "tel-area-code" | "tel-local" | "tel-extension" | "impp" | "photo",'off',false,false ion-searchbar,prop,autocorrect,"off" | "on",'off',false,false ion-searchbar,prop,cancelButtonIcon,string,config.get('backButtonIcon', arrowBackSharp) as string,false,false @@ -1168,6 +1169,8 @@ ion-searchbar,prop,debounce,number | undefined,undefined,false,false ion-searchbar,prop,disabled,boolean,false,false,false ion-searchbar,prop,enterkeyhint,"done" | "enter" | "go" | "next" | "previous" | "search" | "send" | undefined,undefined,false,false ion-searchbar,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false +ion-searchbar,prop,maxlength,number | undefined,undefined,false,false +ion-searchbar,prop,minlength,number | undefined,undefined,false,false ion-searchbar,prop,mode,"ios" | "md",undefined,false,false ion-searchbar,prop,name,string,this.inputId,false,false ion-searchbar,prop,placeholder,string,'Search',false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ab938b5bc1..6102aa8db7 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2552,6 +2552,10 @@ export namespace Components { * If `true`, enable searchbar animation. */ "animated": boolean; + /** + * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. + */ + "autocapitalize": string; /** * Set the input's autocomplete property. */ @@ -2596,6 +2600,14 @@ export namespace Components { * A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`. */ "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; + /** + * This attribute specifies the maximum number of characters that the user can enter. + */ + "maxlength"?: number; + /** + * This attribute specifies the minimum number of characters that the user can enter. + */ + "minlength"?: number; /** * The mode determines which platform styles to use. */ @@ -7280,6 +7292,10 @@ declare namespace LocalJSX { * If `true`, enable searchbar animation. */ "animated"?: boolean; + /** + * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. + */ + "autocapitalize": string; /** * Set the input's autocomplete property. */ @@ -7320,6 +7336,14 @@ declare namespace LocalJSX { * A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`. */ "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; + /** + * This attribute specifies the maximum number of characters that the user can enter. + */ + "maxlength"?: number; + /** + * This attribute specifies the minimum number of characters that the user can enter. + */ + "minlength"?: number; /** * The mode determines which platform styles to use. */ diff --git a/core/src/components/searchbar/searchbar.tsx b/core/src/components/searchbar/searchbar.tsx index 21fed733d2..cf48cf4a04 100644 --- a/core/src/components/searchbar/searchbar.tsx +++ b/core/src/components/searchbar/searchbar.tsx @@ -1,6 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; -import { debounceEvent, raf, componentOnReady } from '@utils/helpers'; +import { debounceEvent, raf, componentOnReady, inheritAttributes } from '@utils/helpers'; +import type { Attributes } from '@utils/helpers'; import { isRTL } from '@utils/rtl'; import { createColorClasses } from '@utils/theme'; import { arrowBackSharp, closeCircle, closeSharp, searchOutline, searchSharp } from 'ionicons/icons'; @@ -28,6 +29,7 @@ export class Searchbar implements ComponentInterface { private shouldAlignLeft = true; private originalIonInput?: EventEmitter; private inputId = `ion-searchbar-${searchbarIds++}`; + private inheritedAttributes: Attributes = {}; /** * The value of the input when the textarea is focused. @@ -39,6 +41,31 @@ export class Searchbar implements ComponentInterface { @State() focused = false; @State() noAnimate = true; + /** + * lang and dir are globally enumerated attributes. + * As a result, creating these as properties + * can have unintended side effects. Instead, we + * listen for attribute changes and inherit them + * to the inner `` element. + */ + @Watch('lang') + onLangChanged(newValue: string) { + this.inheritedAttributes = { + ...this.inheritedAttributes, + lang: newValue, + }; + forceUpdate(this); + } + + @Watch('dir') + onDirChanged(newValue: string) { + this.inheritedAttributes = { + ...this.inheritedAttributes, + dir: newValue, + }; + forceUpdate(this); + } + /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -51,6 +78,27 @@ export class Searchbar implements ComponentInterface { */ @Prop() animated = false; + /** + * Prior to the addition of this property + * autocapitalize was enabled by default on iOS + * and disabled by default on Android + * for Searchbar. The autocapitalize type on HTMLElement + * requires that it be a string and never undefined. + * However, setting it to a string value would be a breaking change + * in behavior, so we use "!" to tell TypeScript that this property + * is always defined so we can rely on the browser defaults. Browsers + * will automatically set a default value if the developer does not set one. + * + * In the future, this property will default to "off" to align with + * Input and Textarea, and the "!" will not be needed. + */ + + /** + * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. + * Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. + */ + @Prop() autocapitalize!: string; + /** * Set the input's autocomplete property. */ @@ -112,6 +160,16 @@ export class Searchbar implements ComponentInterface { */ @Prop() enterkeyhint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'; + /** + * This attribute specifies the maximum number of characters that the user can enter. + */ + @Prop() maxlength?: number; + + /** + * This attribute specifies the minimum number of characters that the user can enter. + */ + @Prop() minlength?: number; + /** * If used in a form, set the name of the control, which is submitted with the form data. */ @@ -232,6 +290,12 @@ export class Searchbar implements ComponentInterface { this.emitStyle(); } + componentWillLoad() { + this.inheritedAttributes = { + ...inheritAttributes(this.el, ['lang', 'dir']), + }; + } + componentDidLoad() { this.originalIonInput = this.ionInput; this.positionElements(); @@ -614,12 +678,16 @@ export class Searchbar implements ComponentInterface { onChange={this.onChange} onBlur={this.onBlur} onFocus={this.onFocus} + minLength={this.minlength} + maxLength={this.maxlength} placeholder={this.placeholder} type={this.type} value={this.getValue()} + autoCapitalize={this.autocapitalize} autoComplete={this.autocomplete} autoCorrect={this.autocorrect} spellcheck={this.spellcheck} + {...this.inheritedAttributes} /> {mode === 'md' && cancelButton} diff --git a/core/src/components/searchbar/test/searchbar.spec.ts b/core/src/components/searchbar/test/searchbar.spec.ts index ad3a1f9137..0622a69ace 100644 --- a/core/src/components/searchbar/test/searchbar.spec.ts +++ b/core/src/components/searchbar/test/searchbar.spec.ts @@ -3,13 +3,37 @@ import { newSpecPage } from '@stencil/core/testing'; import { Searchbar } from '../searchbar'; describe('searchbar: rendering', () => { - it('should inherit attributes', async () => { + it('should inherit properties on load', async () => { const page = await newSpecPage({ components: [Searchbar], - html: '', + html: '', }); const nativeEl = page.body.querySelector('ion-searchbar input')!; expect(nativeEl.getAttribute('name')).toBe('search'); + expect(nativeEl.getAttribute('maxlength')).toBe('4'); + expect(nativeEl.getAttribute('minlength')).toBe('2'); + expect(nativeEl.getAttribute('autocapitalize')).toBe('off'); + }); + + it('should inherit watched attributes', async () => { + const page = await newSpecPage({ + components: [Searchbar], + html: '', + }); + + const searchbarEl = page.body.querySelector('ion-searchbar')!; + const nativeEl = searchbarEl.querySelector('input')!; + + expect(nativeEl.getAttribute('lang')).toBe('en-US'); + expect(nativeEl.getAttribute('dir')).toBe('ltr'); + + searchbarEl.setAttribute('lang', 'es-ES'); + searchbarEl.setAttribute('dir', 'rtl'); + + await page.waitForChanges(); + + expect(nativeEl.getAttribute('lang')).toBe('es-ES'); + expect(nativeEl.getAttribute('dir')).toBe('rtl'); }); }); diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index a592c55894..b60fd38f7f 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1788,7 +1788,7 @@ export declare interface IonRow extends Components.IonRow {} @ProxyCmp({ - inputs: ['animated', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], + inputs: ['animated', 'autocapitalize', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], methods: ['setFocus', 'getInputElement'] }) @Component({ @@ -1796,7 +1796,7 @@ export declare interface IonRow extends Components.IonRow {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['animated', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], + inputs: ['animated', 'autocapitalize', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], }) export class IonSearchbar { protected el: HTMLElement; diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 57380e47bd..5d0272ce08 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -676,6 +676,7 @@ export const IonRow = /*@__PURE__*/ defineContainer('ion-row', defin export const IonSearchbar = /*@__PURE__*/ defineContainer('ion-searchbar', defineIonSearchbar, [ 'color', 'animated', + 'autocapitalize', 'autocomplete', 'autocorrect', 'cancelButtonIcon', @@ -685,6 +686,8 @@ export const IonSearchbar = /*@__PURE__*/ defineContainer