From 597bc3f085c5ff1451c73d0cf4d7d664943e712f Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 28 Sep 2023 11:40:46 -0400 Subject: [PATCH] feat(datetime): add support for h11 and h24 hour formats (#28219) Issue number: resolves #23750 --------- ## What is the current behavior? Datetime does not support h11 and h24 hour formats ## What is the new behavior? - Datetime supports h11 and h24 formats ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Implementation Notes: 1. I broke up the `is24Hour` function into two functions: - The first function, `is24Hour`, accepts an hour cycle and returns true if the hourCycle preference uses a 24 hour format - The second function, getHourCycle, accepts a locale and an optional hour cycle and returns the computed hour cycle. I found that the hour cycle is not always set via `hourCycle` (such as when we are using the system default if it's specified in the `locale` prop using locale extension tags). This was coupled to is24Hour, but I needed this functionality elsewhere to add support for this feature, so I decided to break the functions up. 2. We were using the hour cycle types in several places, so I decided to create a shared `DatetimeHourCycle` to avoid accidental typos. --- core/api.txt | 2 +- core/src/components.d.ts | 8 +- .../datetime-button/datetime-button.tsx | 8 +- .../components/datetime/datetime-interface.ts | 2 + core/src/components/datetime/datetime.tsx | 9 +- .../src/components/datetime/test/data.spec.ts | 153 ++++++++++++++++-- .../components/datetime/test/format.spec.ts | 24 +-- .../components/datetime/test/helpers.spec.ts | 33 ++-- .../datetime/test/hour-cycle/datetime.e2e.ts | 22 +++ .../datetime/test/hour-cycle/index.html | 8 + core/src/components/datetime/utils/data.ts | 51 ++++-- core/src/components/datetime/utils/format.ts | 41 +++-- core/src/components/datetime/utils/helpers.ts | 45 +++++- 13 files changed, 333 insertions(+), 73 deletions(-) diff --git a/core/api.txt b/core/api.txt index d8ddc4ae8f..df776ec760 100644 --- a/core/api.txt +++ b/core/api.txt @@ -394,7 +394,7 @@ 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,highlightedDates,((dateIsoString: string) => DatetimeHighlightStyle | undefined) | DatetimeHighlight[] | undefined,undefined,false,false -ion-datetime,prop,hourCycle,"h12" | "h23" | undefined,undefined,false,false +ion-datetime,prop,hourCycle,"h11" | "h12" | "h23" | "h24" | undefined,undefined,false,false ion-datetime,prop,hourValues,number | number[] | string | undefined,undefined,false,false ion-datetime,prop,isDateEnabled,((dateIsoString: string) => boolean) | undefined,undefined,false,false ion-datetime,prop,locale,string,'default',false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 62cfd30764..296e1490b8 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, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, 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, DatetimePresentation, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, 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"; @@ -865,7 +865,7 @@ export namespace Components { /** * The hour cycle of the `ion-datetime`. If no value is set, this is specified by the current locale. */ - "hourCycle"?: 'h23' | 'h12'; + "hourCycle"?: DatetimeHourCycle; /** * Values used to create the list of selectable hours. By default the hour values range from `0` to `23` for 24-hour, or `1` to `12` for 12-hour. However, to control exactly which hours to display, the `hourValues` input can take a number, an array of numbers, or a string of comma separated numbers. */ @@ -4889,7 +4889,7 @@ declare namespace LocalJSX { /** * The hour cycle of the `ion-datetime`. If no value is set, this is specified by the current locale. */ - "hourCycle"?: 'h23' | 'h12'; + "hourCycle"?: DatetimeHourCycle; /** * Values used to create the list of selectable hours. By default the hour values range from `0` to `23` for 24-hour, or `1` to `12` for 12-hour. However, to control exactly which hours to display, the `hourValues` input can take a number, an array of numbers, or a string of comma separated numbers. */ diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index 43467911c8..7ce3ca4163 100644 --- a/core/src/components/datetime-button/datetime-button.tsx +++ b/core/src/components/datetime-button/datetime-button.tsx @@ -9,7 +9,7 @@ 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 { is24Hour } from '../datetime/utils/helpers'; +import { getHourCycle } from '../datetime/utils/helpers'; import { parseDate } from '../datetime/utils/parse'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. @@ -218,7 +218,7 @@ export class DatetimeButton implements ComponentInterface { * warning in the console. */ const firstParsedDatetime = parsedDatetimes[0]; - const use24Hour = is24Hour(locale, hourCycle); + const computedHourCycle = getHourCycle(locale, hourCycle); this.dateText = this.timeText = undefined; @@ -226,7 +226,7 @@ export class DatetimeButton implements ComponentInterface { case 'date-time': case 'time-date': const dateText = getMonthDayAndYear(locale, firstParsedDatetime); - const timeText = getLocalizedTime(locale, firstParsedDatetime, use24Hour); + const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle); if (preferWheel) { this.dateText = `${dateText} ${timeText}`; } else { @@ -250,7 +250,7 @@ export class DatetimeButton implements ComponentInterface { } break; case 'time': - this.timeText = getLocalizedTime(locale, firstParsedDatetime, use24Hour); + this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle); break; case 'month-year': this.dateText = getMonthAndYear(locale, firstParsedDatetime); diff --git a/core/src/components/datetime/datetime-interface.ts b/core/src/components/datetime/datetime-interface.ts index 5f2772d6c4..255f39e22d 100644 --- a/core/src/components/datetime/datetime-interface.ts +++ b/core/src/components/datetime/datetime-interface.ts @@ -34,3 +34,5 @@ export type DatetimeHighlightStyle = export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle; export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined; + +export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24'; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index d0b596d67c..6f9cd669ac 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -19,6 +19,7 @@ import type { DatetimeHighlight, DatetimeHighlightStyle, DatetimeHighlightCallback, + DatetimeHourCycle, } from './datetime-interface'; import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison'; import { @@ -33,7 +34,7 @@ import { getCombinedDateColumnData, } from './utils/data'; import { formatValue, getLocalizedTime, getMonthAndDay, getMonthAndYear } from './utils/format'; -import { is24Hour, isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth } from './utils/helpers'; +import { isLocaleDayPeriodRTL, isMonthFirstLocale, getNumDaysInMonth, getHourCycle } from './utils/helpers'; import { calculateHourFromAMPM, convertDataToISO, @@ -422,7 +423,7 @@ export class Datetime implements ComponentInterface { * The hour cycle of the `ion-datetime`. If no value is set, this is * specified by the current locale. */ - @Prop() hourCycle?: 'h23' | 'h12'; + @Prop() hourCycle?: DatetimeHourCycle; /** * If `cover`, the `ion-datetime` will expand to cover the full width of its container. @@ -2237,7 +2238,7 @@ export class Datetime implements ComponentInterface { private renderTimeOverlay() { const { hourCycle, isTimePopoverOpen, locale } = this; - const use24Hour = is24Hour(locale, hourCycle); + const computedHourCycle = getHourCycle(locale, hourCycle); const activePart = this.getActivePartsWithFallback(); return [ @@ -2270,7 +2271,7 @@ export class Datetime implements ComponentInterface { } }} > - {getLocalizedTime(locale, activePart, use24Hour)} + {getLocalizedTime(locale, activePart, computedHourCycle)} , { + it('should generate formatted h12 hours and AM/PM data data', () => { + const refParts = { month: 5, year: 2021, day: 1, hour: 4, minute: 30 }; + const results = getTimeColumnsData('en-US', refParts, 'h12'); + + expect(results.hoursData).toEqual([ + { text: '12', value: 0 }, + { text: '1', value: 1 }, + { text: '2', value: 2 }, + { text: '3', value: 3 }, + { text: '4', value: 4 }, + { text: '5', value: 5 }, + { text: '6', value: 6 }, + { text: '7', value: 7 }, + { text: '8', value: 8 }, + { text: '9', value: 9 }, + { text: '10', value: 10 }, + { text: '11', value: 11 }, + ]); + expect(results.dayPeriodData).toEqual([ + { text: 'AM', value: 'am' }, + { text: 'PM', value: 'pm' }, + ]); + }); + it('should generate formatted h23 hours and AM/PM data data', () => { + const refParts = { month: 5, year: 2021, day: 1, hour: 4, minute: 30 }; + const results = getTimeColumnsData('en-US', refParts, 'h23'); + + expect(results.hoursData).toEqual([ + { text: '00', value: 0 }, + { text: '01', value: 1 }, + { text: '02', value: 2 }, + { text: '03', value: 3 }, + { text: '04', value: 4 }, + { text: '05', value: 5 }, + { text: '06', value: 6 }, + { text: '07', value: 7 }, + { text: '08', value: 8 }, + { text: '09', value: 9 }, + { text: '10', value: 10 }, + { text: '11', value: 11 }, + { text: '12', value: 12 }, + { text: '13', value: 13 }, + { text: '14', value: 14 }, + { text: '15', value: 15 }, + { text: '16', value: 16 }, + { text: '17', value: 17 }, + { text: '18', value: 18 }, + { text: '19', value: 19 }, + { text: '20', value: 20 }, + { text: '21', value: 21 }, + { text: '22', value: 22 }, + { text: '23', value: 23 }, + ]); + expect(results.dayPeriodData).toEqual([]); + }); + it('should generate formatted h11 hours and AM/PM data data', () => { + const refParts = { month: 5, year: 2021, day: 1, hour: 4, minute: 30 }; + const results = getTimeColumnsData('en-US', refParts, 'h11'); + + expect(results.hoursData).toEqual([ + { text: '0', value: 0 }, + { text: '1', value: 1 }, + { text: '2', value: 2 }, + { text: '3', value: 3 }, + { text: '4', value: 4 }, + { text: '5', value: 5 }, + { text: '6', value: 6 }, + { text: '7', value: 7 }, + { text: '8', value: 8 }, + { text: '9', value: 9 }, + { text: '10', value: 10 }, + { text: '11', value: 11 }, + ]); + expect(results.dayPeriodData).toEqual([ + { text: 'AM', value: 'am' }, + { text: 'PM', value: 'pm' }, + ]); + }); + it('should generate formatted h24 hours and AM/PM data data', () => { + const refParts = { month: 5, year: 2021, day: 1, hour: 4, minute: 30 }; + const results = getTimeColumnsData('en-US', refParts, 'h24'); + + expect(results.hoursData).toEqual([ + { text: '01', value: 1 }, + { text: '02', value: 2 }, + { text: '03', value: 3 }, + { text: '04', value: 4 }, + { text: '05', value: 5 }, + { text: '06', value: 6 }, + { text: '07', value: 7 }, + { text: '08', value: 8 }, + { text: '09', value: 9 }, + { text: '10', value: 10 }, + { text: '11', value: 11 }, + { text: '12', value: 12 }, + { text: '13', value: 13 }, + { text: '14', value: 14 }, + { text: '15', value: 15 }, + { text: '16', value: 16 }, + { text: '17', value: 17 }, + { text: '18', value: 18 }, + { text: '19', value: 19 }, + { text: '20', value: 20 }, + { text: '21', value: 21 }, + { text: '22', value: 22 }, + { text: '23', value: 23 }, + { text: '24', value: 0 }, + ]); + expect(results.dayPeriodData).toEqual([]); + }); +}); describe('generateMonths()', () => { it('should generate correct month data', () => { @@ -41,7 +162,7 @@ describe('generateTime()', () => { hour: 5, minute: 43, }; - const { hours, minutes } = generateTime(today); + const { hours, minutes } = generateTime('en-US', today); expect(hours.length).toEqual(12); expect(minutes.length).toEqual(60); @@ -61,7 +182,7 @@ describe('generateTime()', () => { hour: 2, minute: 40, }; - const { hours, minutes } = generateTime(today, 'h12', min); + const { hours, minutes } = generateTime('en-US', today, 'h12', min); expect(hours.length).toEqual(10); expect(minutes.length).toEqual(60); @@ -81,7 +202,7 @@ describe('generateTime()', () => { hour: 2, minute: 40, }; - const { hours, minutes } = generateTime(today, 'h12', min); + const { hours, minutes } = generateTime('en-US', today, 'h12', min); expect(hours.length).toEqual(12); expect(minutes.length).toEqual(60); @@ -101,7 +222,7 @@ describe('generateTime()', () => { hour: 7, minute: 44, }; - const { hours, minutes } = generateTime(today, 'h12', undefined, max); + const { hours, minutes } = generateTime('en-US', today, 'h12', undefined, max); expect(hours.length).toEqual(8); expect(minutes.length).toEqual(45); @@ -121,7 +242,7 @@ describe('generateTime()', () => { hour: 2, minute: 40, }; - const { hours, minutes } = generateTime(today, 'h12', undefined, max); + const { hours, minutes } = generateTime('en-US', today, 'h12', undefined, max); expect(hours.length).toEqual(12); expect(minutes.length).toEqual(60); @@ -141,7 +262,7 @@ describe('generateTime()', () => { hour: 2, minute: 40, }; - const { hours, minutes } = generateTime(today, 'h12', min); + const { hours, minutes } = generateTime('en-US', today, 'h12', min); expect(hours.length).toEqual(0); expect(minutes.length).toEqual(0); @@ -161,7 +282,7 @@ describe('generateTime()', () => { hour: 2, minute: 40, }; - const { hours, minutes } = generateTime(today, 'h12', undefined, max); + const { hours, minutes } = generateTime('en-US', today, 'h12', undefined, max); expect(hours.length).toEqual(0); expect(minutes.length).toEqual(0); @@ -185,7 +306,7 @@ describe('generateTime()', () => { year: 2021, }; - const { hours, minutes } = generateTime(today, 'h12', min, max); + const { hours, minutes } = generateTime('en-US', today, 'h12', min, max); expect(hours.length).toEqual(12); expect(minutes.length).toEqual(60); @@ -199,7 +320,7 @@ describe('generateTime()', () => { minute: 43, }; - const { hours, minutes } = generateTime(today, 'h12', undefined, undefined, [1, 2, 3], [10, 15, 20]); + const { hours, minutes } = generateTime('en-US', today, 'h12', undefined, undefined, [1, 2, 3], [10, 15, 20]); expect(hours).toStrictEqual([1, 2, 3]); expect(minutes).toStrictEqual([10, 15, 20]); @@ -229,7 +350,7 @@ describe('generateTime()', () => { minute: 14, }; - const { am, pm } = generateTime(today, 'h12', min, max); + const { am, pm } = generateTime('en-US', today, 'h12', min, max); expect(am).toBe(true); expect(pm).toBe(true); @@ -253,7 +374,7 @@ describe('generateTime()', () => { minute: 50, }; - const { hours } = generateTime(refValue, 'h23', minParts); + const { hours } = generateTime('en-US', refValue, 'h23', minParts); expect(hours).toStrictEqual([19, 20, 21, 22, 23]); }); @@ -276,7 +397,7 @@ describe('generateTime()', () => { minute: 30, }; - const { hours, minutes } = generateTime(refValue, 'h23', minParts); + const { hours, minutes } = generateTime('en-US', refValue, 'h23', minParts); expect(hours).toStrictEqual([19, 20, 21, 22, 23]); expect(minutes.length).toEqual(60); @@ -308,7 +429,7 @@ describe('generateTime()', () => { minute: 40, }; - const { hours } = generateTime(refValue, 'h23', minParts, maxParts); + const { hours } = generateTime('en-US', refValue, 'h23', minParts, maxParts); expect(hours).toStrictEqual([19, 20]); }); @@ -330,7 +451,7 @@ describe('generateTime()', () => { minute: 2, }; - const { minutes } = generateTime(refValue, 'h23', undefined, maxParts); + const { minutes } = generateTime('en-US', refValue, 'h23', undefined, maxParts); expect(minutes).toStrictEqual([0, 1, 2]); }); @@ -352,7 +473,7 @@ describe('generateTime()', () => { minute: 2, }; - const { minutes } = generateTime(refValue, 'h23', undefined, maxParts); + const { minutes } = generateTime('en-US', refValue, 'h23', undefined, maxParts); expect(minutes.length).toEqual(60); }); diff --git a/core/src/components/datetime/test/format.spec.ts b/core/src/components/datetime/test/format.spec.ts index d15de48788..b4d5f693bd 100644 --- a/core/src/components/datetime/test/format.spec.ts +++ b/core/src/components/datetime/test/format.spec.ts @@ -56,11 +56,17 @@ describe('getMonthAndDay()', () => { describe('getFormattedHour()', () => { it('should only add padding if using 24 hour time', () => { - expect(getFormattedHour(0, true)).toEqual('00'); - expect(getFormattedHour(0, false)).toEqual('12'); + expect(getFormattedHour(1, 'h11')).toEqual('1'); + expect(getFormattedHour(1, 'h12')).toEqual('1'); + expect(getFormattedHour(1, 'h23')).toEqual('01'); + expect(getFormattedHour(1, 'h24')).toEqual('01'); + }); - expect(getFormattedHour(10, true)).toEqual('10'); - expect(getFormattedHour(10, false)).toEqual('10'); + it('should return correct hour value for hour cycle', () => { + expect(getFormattedHour(0, 'h11')).toEqual('0'); + expect(getFormattedHour(0, 'h12')).toEqual('12'); + expect(getFormattedHour(0, 'h23')).toEqual('00'); + expect(getFormattedHour(0, 'h24')).toEqual('24'); }); }); @@ -111,7 +117,7 @@ describe('getLocalizedTime', () => { minute: 40, }; - expect(getLocalizedTime('en-US', datetimeParts, false)).toEqual('1:40 PM'); + expect(getLocalizedTime('en-US', datetimeParts, 'h12')).toEqual('1:40 PM'); }); it('should localize the time to AM', () => { @@ -123,7 +129,7 @@ describe('getLocalizedTime', () => { minute: 40, }; - expect(getLocalizedTime('en-US', datetimeParts, false)).toEqual('9:40 AM'); + expect(getLocalizedTime('en-US', datetimeParts, 'h12')).toEqual('9:40 AM'); }); it('should avoid Chromium bug when using 12 hour time in a 24 hour locale', () => { @@ -135,7 +141,7 @@ describe('getLocalizedTime', () => { minute: 0, }; - expect(getLocalizedTime('en-GB', datetimeParts, false)).toEqual('12:00 am'); + expect(getLocalizedTime('en-GB', datetimeParts, 'h12')).toEqual('12:00 am'); }); it('should parse time-only values correctly', () => { const datetimeParts = { @@ -143,7 +149,7 @@ describe('getLocalizedTime', () => { minute: 40, }; - expect(getLocalizedTime('en-US', datetimeParts, false)).toEqual('10:40 PM'); - expect(getLocalizedTime('en-US', datetimeParts, true)).toEqual('22:40'); + expect(getLocalizedTime('en-US', datetimeParts, 'h12')).toEqual('10:40 PM'); + expect(getLocalizedTime('en-US', datetimeParts, 'h23')).toEqual('22:40'); }); }); diff --git a/core/src/components/datetime/test/helpers.spec.ts b/core/src/components/datetime/test/helpers.spec.ts index e51f5181f3..2edd72574f 100644 --- a/core/src/components/datetime/test/helpers.spec.ts +++ b/core/src/components/datetime/test/helpers.spec.ts @@ -1,4 +1,4 @@ -import { isLeapYear, getNumDaysInMonth, is24Hour, isMonthFirstLocale } from '../utils/helpers'; +import { isLeapYear, getNumDaysInMonth, is24Hour, isMonthFirstLocale, getHourCycle } from '../utils/helpers'; describe('daysInMonth()', () => { it('should return correct days in month for month and year', () => { @@ -37,14 +37,29 @@ describe('isLeapYear()', () => { describe('is24Hour()', () => { it('should return true if the locale uses 24 hour time', () => { - expect(is24Hour('en-US')).toBe(false); - expect(is24Hour('en-US', 'h23')).toBe(true); - expect(is24Hour('en-US', 'h12')).toBe(false); - expect(is24Hour('en-US-u-hc-h23')).toBe(true); - expect(is24Hour('en-GB')).toBe(true); - expect(is24Hour('en-GB', 'h23')).toBe(true); - expect(is24Hour('en-GB', 'h12')).toBe(false); - expect(is24Hour('en-GB-u-hc-h12')).toBe(false); + expect(is24Hour('h11')).toBe(false); + expect(is24Hour('h12')).toBe(false); + expect(is24Hour('h23')).toBe(true); + expect(is24Hour('h24')).toBe(true); + }); +}); + +describe('getHourCycle()', () => { + it('should return the correct hour cycle', () => { + expect(getHourCycle('en-US')).toBe('h12'); + expect(getHourCycle('en-US', 'h23')).toBe('h23'); + expect(getHourCycle('en-US', 'h12')).toBe('h12'); + expect(getHourCycle('en-US-u-hc-h23')).toBe('h23'); + expect(getHourCycle('en-GB')).toBe('h23'); + expect(getHourCycle('en-GB', 'h23')).toBe('h23'); + expect(getHourCycle('en-GB', 'h12')).toBe('h12'); + expect(getHourCycle('en-GB-u-hc-h12')).toBe('h12'); + + expect(getHourCycle('en-GB', 'h11')).toBe('h11'); + expect(getHourCycle('en-GB-u-hc-h11')).toBe('h11'); + + expect(getHourCycle('en-GB', 'h24')).toBe('h24'); + expect(getHourCycle('en-GB-u-hc-h24')).toBe('h24'); }); }); diff --git a/core/src/components/datetime/test/hour-cycle/datetime.e2e.ts b/core/src/components/datetime/test/hour-cycle/datetime.e2e.ts index 1206b70298..73820a74b4 100644 --- a/core/src/components/datetime/test/hour-cycle/datetime.e2e.ts +++ b/core/src/components/datetime/test/hour-cycle/datetime.e2e.ts @@ -25,5 +25,27 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const timeButton = page.locator('ion-datetime .time-body'); await expect(timeButton).toHaveText('4:30 PM'); }); + test('should set the h11 hour cycle correctly', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const timeButton = page.locator('ion-datetime .time-body'); + await expect(timeButton).toHaveText('0:30 AM'); + }); + test('should set the h24 hour cycle correctly', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const timeButton = page.locator('ion-datetime .time-body'); + await expect(timeButton).toHaveText('24:30'); + }); }); }); diff --git a/core/src/components/datetime/test/hour-cycle/index.html b/core/src/components/datetime/test/hour-cycle/index.html index 0c9450e48c..1c0f64bbca 100644 --- a/core/src/components/datetime/test/hour-cycle/index.html +++ b/core/src/components/datetime/test/hour-cycle/index.html @@ -51,6 +51,14 @@

h12 Hour cycle

+
+

h11 Hour cycle

+ +
+
+

h24 Hour cycle

+ +

h23 Hour cycle (Extension Tag)

diff --git a/core/src/components/datetime/utils/data.ts b/core/src/components/datetime/utils/data.ts index faf782f202..8841e58ffc 100644 --- a/core/src/components/datetime/utils/data.ts +++ b/core/src/components/datetime/utils/data.ts @@ -1,6 +1,6 @@ import type { Mode } from '../../../interface'; import type { PickerColumnItem } from '../../picker-column-internal/picker-column-internal-interfaces'; -import type { DatetimeParts } from '../datetime-interface'; +import type { DatetimeParts, DatetimeHourCycle } from '../datetime-interface'; import { isAfter, isBefore, isSameDay } from './comparison'; import { @@ -11,7 +11,7 @@ import { getTodayLabel, getYear, } from './format'; -import { getNumDaysInMonth, is24Hour } from './helpers'; +import { getNumDaysInMonth, is24Hour, getHourCycle } from './helpers'; import { getNextMonth, getPreviousMonth, getInternalHourValue } from './manipulation'; /** @@ -44,9 +44,19 @@ const minutes = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, ]; + +// h11 hour system uses 0-11. Midnight starts at 0:00am. +const hour11 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + +// h12 hour system uses 0-12. Midnight starts at 12:00am. const hour12 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + +// h23 hour system uses 0-23. Midnight starts at 0:00. const hour23 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]; +// h24 hour system uses 1-24. Midnight starts at 24:00. +const hour24 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 0]; + /** * Given a locale and a mode, * return an array with formatted days @@ -125,21 +135,42 @@ export const getDaysOfMonth = (month: number, year: number, firstDayOfWeek: numb return days; }; +/** + * Returns an array of pre-defined hour + * values based on the provided hourCycle. + */ +const getHourData = (hourCycle: DatetimeHourCycle) => { + switch (hourCycle) { + case 'h11': + return hour11; + case 'h12': + return hour12; + case 'h23': + return hour23; + case 'h24': + return hour24; + default: + throw new Error(`Invalid hour cycle "${hourCycle}"`); + } +}; + /** * Given a local, reference datetime parts and option * max/min bound datetime parts, calculate the acceptable * hour and minute values according to the bounds and locale. */ export const generateTime = ( + locale: string, refParts: DatetimeParts, - hourCycle: 'h12' | 'h23' = 'h12', + hourCycle: DatetimeHourCycle = 'h12', minParts?: DatetimeParts, maxParts?: DatetimeParts, hourValues?: number[], minuteValues?: number[] ) => { - const use24Hour = hourCycle === 'h23'; - let processedHours = use24Hour ? hour23 : hour12; + const computedHourCycle = getHourCycle(locale, hourCycle); + const use24Hour = is24Hour(computedHourCycle); + let processedHours = getHourData(computedHourCycle); let processedMinutes = minutes; let isAMAllowed = true; let isPMAllowed = true; @@ -540,16 +571,18 @@ export const getCombinedDateColumnData = ( export const getTimeColumnsData = ( locale: string, refParts: DatetimeParts, - hourCycle?: 'h23' | 'h12', + hourCycle?: DatetimeHourCycle, minParts?: DatetimeParts, maxParts?: DatetimeParts, allowedHourValues?: number[], allowedMinuteValues?: number[] ): { [key: string]: PickerColumnItem[] } => { - const use24Hour = is24Hour(locale, hourCycle); + const computedHourCycle = getHourCycle(locale, hourCycle); + const use24Hour = is24Hour(computedHourCycle); const { hours, minutes, am, pm } = generateTime( + locale, refParts, - use24Hour ? 'h23' : 'h12', + computedHourCycle, minParts, maxParts, allowedHourValues, @@ -558,7 +591,7 @@ export const getTimeColumnsData = ( const hoursItems = hours.map((hour) => { return { - text: getFormattedHour(hour, use24Hour), + text: getFormattedHour(hour, computedHourCycle), value: getInternalHourValue(hour, use24Hour, refParts.ampm), }; }); diff --git a/core/src/components/datetime/utils/format.ts b/core/src/components/datetime/utils/format.ts index 179bf515e0..0f70299dc5 100644 --- a/core/src/components/datetime/utils/format.ts +++ b/core/src/components/datetime/utils/format.ts @@ -1,5 +1,6 @@ -import type { DatetimeParts } from '../datetime-interface'; +import type { DatetimeParts, DatetimeHourCycle } from '../datetime-interface'; +import { is24Hour } from './helpers'; import { convertDataToISO } from './manipulation'; const getFormattedDayPeriod = (dayPeriod?: string) => { @@ -10,7 +11,7 @@ const getFormattedDayPeriod = (dayPeriod?: string) => { return dayPeriod.toUpperCase(); }; -export const getLocalizedTime = (locale: string, refParts: DatetimeParts, use24Hour: boolean): string => { +export const getLocalizedTime = (locale: string, refParts: DatetimeParts, hourCycle: DatetimeHourCycle): string => { const timeParts: Pick = { hour: refParts.hour, minute: refParts.minute, @@ -34,7 +35,7 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, use24H * We use hourCycle here instead of hour12 due to: * https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2 */ - hourCycle: use24Hour ? 'h23' : 'h12', + hourCycle, /** * Setting Z at the end indicates that this * date string is in the UTC time zone. This @@ -83,18 +84,34 @@ export const addTimePadding = (value: number): string => { * 12 hour times it ensures that * hour 0 is formatted as '12'. */ -export const getFormattedHour = (hour: number, use24Hour: boolean): string => { - if (use24Hour) { - return addTimePadding(hour); - } - +export const getFormattedHour = (hour: number, hourCycle: DatetimeHourCycle): string => { /** - * If using 12 hour - * format, make sure hour - * 0 is formatted as '12'. + * Midnight for h11 starts at 0:00am + * Midnight for h12 starts at 12:00am + * Midnight for h23 starts at 00:00 + * Midnight for h24 starts at 24:00 */ if (hour === 0) { - return '12'; + switch (hourCycle) { + case 'h11': + return '0'; + case 'h12': + return '12'; + case 'h23': + return '00'; + case 'h24': + return '24'; + default: + throw new Error(`Invalid hour cycle "${hourCycle}"`); + } + } + + const use24Hour = is24Hour(hourCycle); + /** + * h23 and h24 use 24 hour times. + */ + if (use24Hour) { + return addTimePadding(hour); } return hour.toString(); diff --git a/core/src/components/datetime/utils/helpers.ts b/core/src/components/datetime/utils/helpers.ts index e104de27f6..a0df7a9946 100644 --- a/core/src/components/datetime/utils/helpers.ts +++ b/core/src/components/datetime/utils/helpers.ts @@ -1,3 +1,5 @@ +import type { DatetimeHourCycle } from '../datetime-interface'; + /** * Determines if given year is a * leap year. Returns `true` if year @@ -8,13 +10,19 @@ export const isLeapYear = (year: number) => { return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; }; -export const is24Hour = (locale: string, hourCycle?: 'h23' | 'h12') => { +/** + * Determines the hour cycle for a user. + * If the hour cycle is explicitly defined, just use that. + * Otherwise, we try to derive it from either the specified + * locale extension tags or from Intl.DateTimeFormat directly. + */ +export const getHourCycle = (locale: string, hourCycle?: DatetimeHourCycle) => { /** - * If developer has explicitly enabled h23 time + * If developer has explicitly enabled 24-hour time * then return early and do not look at the system default. */ if (hourCycle !== undefined) { - return hourCycle === 'h23'; + return hourCycle; } /** @@ -26,7 +34,7 @@ export const is24Hour = (locale: string, hourCycle?: 'h23' | 'h12') => { const formatted = new Intl.DateTimeFormat(locale, { hour: 'numeric' }); const options = formatted.resolvedOptions(); if (options.hourCycle !== undefined) { - return options.hourCycle === 'h23'; + return options.hourCycle; } /** @@ -42,7 +50,34 @@ export const is24Hour = (locale: string, hourCycle?: 'h23' | 'h12') => { throw new Error('Hour value not found from DateTimeFormat'); } - return hour.value === '00'; + /** + * Midnight for h11 starts at 0:00am + * Midnight for h12 starts at 12:00am + * Midnight for h23 starts at 00:00 + * Midnight for h24 starts at 24:00 + */ + switch (hour.value) { + case '0': + return 'h11'; + case '12': + return 'h12'; + case '00': + return 'h23'; + case '24': + return 'h24'; + default: + throw new Error(`Invalid hour cycle "${hourCycle}"`); + } +}; + +/** + * Determine if the hour cycle uses a 24-hour format. + * Returns true for h23 and h24. Returns false otherwise. + * If you don't know the hourCycle, use getHourCycle above + * and pass the result into this function. + */ +export const is24Hour = (hourCycle: DatetimeHourCycle) => { + return hourCycle === 'h23' || hourCycle === 'h24'; }; /**