diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index a8c9d46ab6..bb8d0bad26 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -1545,7 +1545,7 @@ export class Datetime implements ComponentInterface { const shouldRenderYears = forcePresentation !== 'month' && forcePresentation !== 'time'; const years = shouldRenderYears - ? getYearColumnData(this.todayParts, this.minParts, this.maxParts, this.parsedYearValues) + ? getYearColumnData(this.locale, this.todayParts, this.minParts, this.maxParts, this.parsedYearValues) : []; /** @@ -1910,7 +1910,7 @@ export class Datetime implements ComponentInterface { const { day, dayOfWeek } = dateObject; const { isDateEnabled, multiple } = this; const referenceParts = { month, day, year }; - const { isActive, isToday, ariaLabel, ariaSelected, disabled } = getCalendarDayState( + const { isActive, isToday, ariaLabel, ariaSelected, disabled, text } = getCalendarDayState( this.locale, referenceParts, this.activePartsClone, @@ -1987,7 +1987,7 @@ export class Datetime implements ComponentInterface { } }} > - {day} + {text} ); })} diff --git a/core/src/components/datetime/test/locale/datetime.e2e.ts b/core/src/components/datetime/test/locale/datetime.e2e.ts index a15a23820d..aaee56f5ac 100644 --- a/core/src/components/datetime/test/locale/datetime.e2e.ts +++ b/core/src/components/datetime/test/locale/datetime.e2e.ts @@ -62,6 +62,26 @@ test.describe('datetime: locale', () => { test('time picker should not have visual regressions', async () => { await datetimeFixture.expectLocalizedTimePicker(); }); + + test('should correctly localize calendar day buttons without literal', async ({ page }) => { + await page.setContent(` + + `); + + await page.waitForSelector('.datetime-ready'); + + const datetimeButtons = page.locator('ion-datetime .calendar-day:not([disabled])'); + + /** + * Note: The Intl.DateTimeFormat typically adds literals + * for certain languages. For Japanese, that could look + * something like "29日". However, we only want the "29" + * to be shown. + */ + await expect(datetimeButtons.nth(0)).toHaveText('1'); + await expect(datetimeButtons.nth(1)).toHaveText('2'); + await expect(datetimeButtons.nth(2)).toHaveText('3'); + }); }); test.describe('es-ES', () => { @@ -83,6 +103,40 @@ test.describe('datetime: locale', () => { }); }); +test.describe('ar-EG', () => { + test.beforeEach(async ({ skip }) => { + skip.rtl(); + skip.mode('md'); + }); + + test('should correctly localize calendar day buttons', async ({ page }) => { + await page.setContent(` + + `); + + await page.waitForSelector('.datetime-ready'); + + const datetimeButtons = page.locator('ion-datetime .calendar-day:not([disabled])'); + + await expect(datetimeButtons.nth(0)).toHaveText('١'); + await expect(datetimeButtons.nth(1)).toHaveText('٢'); + await expect(datetimeButtons.nth(2)).toHaveText('٣'); + }); + + test('should correctly localize year column data', async ({ page }) => { + await page.setContent(` + + `); + await page.waitForSelector('.datetime-ready'); + + const datetimeYears = page.locator('ion-datetime .year-column .picker-item:not(.picker-item-empty)'); + + await expect(datetimeYears.nth(0)).toHaveText('٢٠٢٢'); + await expect(datetimeYears.nth(1)).toHaveText('٢٠٢١'); + await expect(datetimeYears.nth(2)).toHaveText('٢٠٢٠'); + }); +}); + class DatetimeLocaleFixture { readonly page: E2EPage; locale = 'en-US'; diff --git a/core/src/components/datetime/test/state.spec.ts b/core/src/components/datetime/test/state.spec.ts index a6c35425c1..71cc968c8b 100644 --- a/core/src/components/datetime/test/state.spec.ts +++ b/core/src/components/datetime/test/state.spec.ts @@ -12,6 +12,7 @@ describe('getCalendarDayState()', () => { disabled: false, ariaSelected: null, ariaLabel: 'Tuesday, January 1', + text: '1', }); expect(getCalendarDayState('en-US', refA, refA, refC)).toEqual({ @@ -20,6 +21,7 @@ describe('getCalendarDayState()', () => { disabled: false, ariaSelected: 'true', ariaLabel: 'Tuesday, January 1', + text: '1', }); expect(getCalendarDayState('en-US', refA, refB, refA)).toEqual({ @@ -28,6 +30,7 @@ describe('getCalendarDayState()', () => { disabled: false, ariaSelected: null, ariaLabel: 'Today, Tuesday, January 1', + text: '1', }); expect(getCalendarDayState('en-US', refA, refA, refA)).toEqual({ @@ -36,6 +39,7 @@ describe('getCalendarDayState()', () => { disabled: false, ariaSelected: 'true', ariaLabel: 'Today, Tuesday, January 1', + text: '1', }); expect(getCalendarDayState('en-US', refA, refA, refA, undefined, undefined, [1])).toEqual({ @@ -44,6 +48,7 @@ describe('getCalendarDayState()', () => { disabled: false, ariaSelected: 'true', ariaLabel: 'Today, Tuesday, January 1', + text: '1', }); expect(getCalendarDayState('en-US', refA, refA, refA, undefined, undefined, [2])).toEqual({ @@ -52,6 +57,7 @@ describe('getCalendarDayState()', () => { disabled: true, ariaSelected: 'true', ariaLabel: 'Today, Tuesday, January 1', + text: '1', }); }); }); diff --git a/core/src/components/datetime/utils/data.ts b/core/src/components/datetime/utils/data.ts index ed11abc040..073541dbe1 100644 --- a/core/src/components/datetime/utils/data.ts +++ b/core/src/components/datetime/utils/data.ts @@ -3,7 +3,14 @@ import type { PickerColumnItem } from '../../picker-column-internal/picker-colum import type { DatetimeParts } from '../datetime-interface'; import { isAfter, isBefore, isSameDay } from './comparison'; -import { getLocalizedDayPeriod, removeDateTzOffset, getFormattedHour, addTimePadding, getTodayLabel } from './format'; +import { + getLocalizedDayPeriod, + removeDateTzOffset, + getFormattedHour, + addTimePadding, + getTodayLabel, + getYear, +} from './format'; import { getNumDaysInMonth, is24Hour } from './helpers'; import { getNextMonth, getPreviousMonth, getInternalHourValue } from './manipulation'; @@ -378,6 +385,7 @@ export const getDayColumnData = ( }; export const getYearColumnData = ( + locale: string, refParts: DatetimeParts, minParts?: DatetimeParts, maxParts?: DatetimeParts, @@ -403,7 +411,7 @@ export const getYearColumnData = ( } return processedYears.map((year) => ({ - text: `${year}`, + text: getYear(locale, { year, month: refParts.month, day: refParts.day }), value: year, })); }; diff --git a/core/src/components/datetime/utils/format.ts b/core/src/components/datetime/utils/format.ts index 3869d376b4..2ea4980381 100644 --- a/core/src/components/datetime/utils/format.ts +++ b/core/src/components/datetime/utils/format.ts @@ -119,19 +119,73 @@ export const getMonthDayAndYear = (locale: string, refParts: DatetimeParts) => { }; /** - * Wrapper function for Intl.DateTimeFormat. - * Allows developers to apply an allowed format to DatetimeParts. - * This function also has built in safeguards for older browser bugs - * with Intl.DateTimeFormat. + * Given a locale and a date object, + * return a formatted string that includes + * the numeric day. + * Note: Some languages will add literal characters + * to the end. This function removes those literals. + * Example: 29 + */ +export const getDay = (locale: string, refParts: DatetimeParts) => { + return getLocalizedDateTimeParts(locale, refParts, { day: 'numeric' }).find((obj) => obj.type === 'day')!.value; +}; + +/** + * Given a locale and a date object, + * return a formatted string that includes + * the numeric year. + * Example: 2022 + */ +export const getYear = (locale: string, refParts: DatetimeParts) => { + return getLocalizedDateTime(locale, refParts, { year: 'numeric' }); +}; + +const getNormalizedDate = (refParts: DatetimeParts) => { + const timeString = !!refParts.hour && !!refParts.minute ? ` ${refParts.hour}:${refParts.minute}` : ''; + + return new Date(`${refParts.month}/${refParts.day}/${refParts.year}${timeString} GMT+0000`); +}; + +/** + * Given a locale, DatetimeParts, and options + * format the DatetimeParts according to the options + * and locale combination. This returns a string. If + * you want an array of the individual pieces + * that make up the localized date string, use + * getLocalizedDateTimeParts. */ export const getLocalizedDateTime = ( locale: string, refParts: DatetimeParts, options: Intl.DateTimeFormatOptions ): string => { - const timeString = !!refParts.hour && !!refParts.minute ? ` ${refParts.hour}:${refParts.minute}` : ''; - const date = new Date(`${refParts.month}/${refParts.day}/${refParts.year}${timeString} GMT+0000`); - return new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }).format(date); + const date = getNormalizedDate(refParts); + return getDateTimeFormat(locale, options).format(date); +}; + +/** + * Given a locale, DatetimeParts, and options + * format the DatetimeParts according to the options + * and locale combination. This returns an array of + * each piece of the date. + */ +export const getLocalizedDateTimeParts = ( + locale: string, + refParts: DatetimeParts, + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormatPart[] => { + const date = getNormalizedDate(refParts); + return getDateTimeFormat(locale, options).formatToParts(date); +}; + +/** + * Wrapper function for Intl.DateTimeFormat. + * Allows developers to apply an allowed format to DatetimeParts. + * This function also has built in safeguards for older browser bugs + * with Intl.DateTimeFormat. + */ +const getDateTimeFormat = (locale: string, options: Intl.DateTimeFormatOptions) => { + return new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }); }; /** diff --git a/core/src/components/datetime/utils/state.ts b/core/src/components/datetime/utils/state.ts index 1ad345d4e1..0d9200ffeb 100644 --- a/core/src/components/datetime/utils/state.ts +++ b/core/src/components/datetime/utils/state.ts @@ -1,7 +1,7 @@ import type { DatetimeParts } from '../datetime-interface'; import { isAfter, isBefore, isSameDay } from './comparison'; -import { generateDayAriaLabel } from './format'; +import { generateDayAriaLabel, getDay } from './format'; import { getNextMonth, getPreviousMonth } from './manipulation'; export const isYearDisabled = (refYear: number, minParts?: DatetimeParts, maxParts?: DatetimeParts) => { @@ -123,6 +123,7 @@ export const getCalendarDayState = ( isToday, ariaSelected: isActive ? 'true' : null, ariaLabel: generateDayAriaLabel(locale, isToday, refParts), + text: refParts.day != null ? getDay(locale, refParts) : null, }; };