feat(datetime): add support for h11 and h24 hour formats (#28219)

Issue number: resolves #23750

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Datetime does not support h11 and h24 hour formats

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Datetime supports h11 and h24 formats

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->


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.
This commit is contained in:
Liam DeBeasi
2023-09-28 11:40:46 -04:00
committed by GitHub
parent d0d9e35c37
commit 597bc3f085
13 changed files with 333 additions and 73 deletions

View File

@ -394,7 +394,7 @@ ion-datetime,prop,disabled,boolean,false,false,false
ion-datetime,prop,doneText,string,'Done',false,false ion-datetime,prop,doneText,string,'Done',false,false
ion-datetime,prop,firstDayOfWeek,number,0,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,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,hourValues,number | number[] | string | undefined,undefined,false,false
ion-datetime,prop,isDateEnabled,((dateIsoString: string) => boolean) | undefined,undefined,false,false ion-datetime,prop,isDateEnabled,((dateIsoString: string) => boolean) | undefined,undefined,false,false
ion-datetime,prop,locale,string,'default',false,false ion-datetime,prop,locale,string,'default',false,false

View File

@ -15,7 +15,7 @@ import { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo
import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-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 { SpinnerTypes } from "./components/spinner/spinner-configs";
import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
import { CounterFormatter } from "./components/item/item-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 { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface";
export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface";
export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-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 { SpinnerTypes } from "./components/spinner/spinner-configs";
export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface";
export { CounterFormatter } from "./components/item/item-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. * 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. * 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. * 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. * 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.
*/ */

View File

@ -9,7 +9,7 @@ import type { Color } from '../../interface';
import type { DatetimePresentation } from '../datetime/datetime-interface'; import type { DatetimePresentation } from '../datetime/datetime-interface';
import { getToday } from '../datetime/utils/data'; import { getToday } from '../datetime/utils/data';
import { getMonthAndYear, getMonthDayAndYear, getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; 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'; import { parseDate } from '../datetime/utils/parse';
/** /**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @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. * warning in the console.
*/ */
const firstParsedDatetime = parsedDatetimes[0]; const firstParsedDatetime = parsedDatetimes[0];
const use24Hour = is24Hour(locale, hourCycle); const computedHourCycle = getHourCycle(locale, hourCycle);
this.dateText = this.timeText = undefined; this.dateText = this.timeText = undefined;
@ -226,7 +226,7 @@ export class DatetimeButton implements ComponentInterface {
case 'date-time': case 'date-time':
case 'time-date': case 'time-date':
const dateText = getMonthDayAndYear(locale, firstParsedDatetime); const dateText = getMonthDayAndYear(locale, firstParsedDatetime);
const timeText = getLocalizedTime(locale, firstParsedDatetime, use24Hour); const timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle);
if (preferWheel) { if (preferWheel) {
this.dateText = `${dateText} ${timeText}`; this.dateText = `${dateText} ${timeText}`;
} else { } else {
@ -250,7 +250,7 @@ export class DatetimeButton implements ComponentInterface {
} }
break; break;
case 'time': case 'time':
this.timeText = getLocalizedTime(locale, firstParsedDatetime, use24Hour); this.timeText = getLocalizedTime(locale, firstParsedDatetime, computedHourCycle);
break; break;
case 'month-year': case 'month-year':
this.dateText = getMonthAndYear(locale, firstParsedDatetime); this.dateText = getMonthAndYear(locale, firstParsedDatetime);

View File

@ -34,3 +34,5 @@ export type DatetimeHighlightStyle =
export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle; export type DatetimeHighlight = { date: string } & DatetimeHighlightStyle;
export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined; export type DatetimeHighlightCallback = (dateIsoString: string) => DatetimeHighlightStyle | undefined;
export type DatetimeHourCycle = 'h11' | 'h12' | 'h23' | 'h24';

View File

@ -19,6 +19,7 @@ import type {
DatetimeHighlight, DatetimeHighlight,
DatetimeHighlightStyle, DatetimeHighlightStyle,
DatetimeHighlightCallback, DatetimeHighlightCallback,
DatetimeHourCycle,
} from './datetime-interface'; } from './datetime-interface';
import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison'; import { isSameDay, warnIfValueOutOfBounds, isBefore, isAfter } from './utils/comparison';
import { import {
@ -33,7 +34,7 @@ import {
getCombinedDateColumnData, getCombinedDateColumnData,
} from './utils/data'; } from './utils/data';
import { formatValue, getLocalizedTime, getMonthAndDay, getMonthAndYear } from './utils/format'; 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 { import {
calculateHourFromAMPM, calculateHourFromAMPM,
convertDataToISO, convertDataToISO,
@ -422,7 +423,7 @@ export class Datetime implements ComponentInterface {
* The hour cycle of the `ion-datetime`. If no value is set, this is * The hour cycle of the `ion-datetime`. If no value is set, this is
* specified by the current locale. * 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. * 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() { private renderTimeOverlay() {
const { hourCycle, isTimePopoverOpen, locale } = this; const { hourCycle, isTimePopoverOpen, locale } = this;
const use24Hour = is24Hour(locale, hourCycle); const computedHourCycle = getHourCycle(locale, hourCycle);
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
return [ return [
@ -2270,7 +2271,7 @@ export class Datetime implements ComponentInterface {
} }
}} }}
> >
{getLocalizedTime(locale, activePart, use24Hour)} {getLocalizedTime(locale, activePart, computedHourCycle)}
</button>, </button>,
<ion-popover <ion-popover
alignment="center" alignment="center"

View File

@ -1,4 +1,125 @@
import { generateMonths, getDaysOfWeek, generateTime, getToday, getCombinedDateColumnData } from '../utils/data'; import {
generateMonths,
getDaysOfWeek,
generateTime,
getToday,
getCombinedDateColumnData,
getTimeColumnsData,
} from '../utils/data';
// The minutes are the same across all hour cycles, so we don't check those
describe('getTimeColumnsData()', () => {
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()', () => { describe('generateMonths()', () => {
it('should generate correct month data', () => { it('should generate correct month data', () => {
@ -41,7 +162,7 @@ describe('generateTime()', () => {
hour: 5, hour: 5,
minute: 43, minute: 43,
}; };
const { hours, minutes } = generateTime(today); const { hours, minutes } = generateTime('en-US', today);
expect(hours.length).toEqual(12); expect(hours.length).toEqual(12);
expect(minutes.length).toEqual(60); expect(minutes.length).toEqual(60);
@ -61,7 +182,7 @@ describe('generateTime()', () => {
hour: 2, hour: 2,
minute: 40, minute: 40,
}; };
const { hours, minutes } = generateTime(today, 'h12', min); const { hours, minutes } = generateTime('en-US', today, 'h12', min);
expect(hours.length).toEqual(10); expect(hours.length).toEqual(10);
expect(minutes.length).toEqual(60); expect(minutes.length).toEqual(60);
@ -81,7 +202,7 @@ describe('generateTime()', () => {
hour: 2, hour: 2,
minute: 40, minute: 40,
}; };
const { hours, minutes } = generateTime(today, 'h12', min); const { hours, minutes } = generateTime('en-US', today, 'h12', min);
expect(hours.length).toEqual(12); expect(hours.length).toEqual(12);
expect(minutes.length).toEqual(60); expect(minutes.length).toEqual(60);
@ -101,7 +222,7 @@ describe('generateTime()', () => {
hour: 7, hour: 7,
minute: 44, 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(hours.length).toEqual(8);
expect(minutes.length).toEqual(45); expect(minutes.length).toEqual(45);
@ -121,7 +242,7 @@ describe('generateTime()', () => {
hour: 2, hour: 2,
minute: 40, 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(hours.length).toEqual(12);
expect(minutes.length).toEqual(60); expect(minutes.length).toEqual(60);
@ -141,7 +262,7 @@ describe('generateTime()', () => {
hour: 2, hour: 2,
minute: 40, minute: 40,
}; };
const { hours, minutes } = generateTime(today, 'h12', min); const { hours, minutes } = generateTime('en-US', today, 'h12', min);
expect(hours.length).toEqual(0); expect(hours.length).toEqual(0);
expect(minutes.length).toEqual(0); expect(minutes.length).toEqual(0);
@ -161,7 +282,7 @@ describe('generateTime()', () => {
hour: 2, hour: 2,
minute: 40, 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(hours.length).toEqual(0);
expect(minutes.length).toEqual(0); expect(minutes.length).toEqual(0);
@ -185,7 +306,7 @@ describe('generateTime()', () => {
year: 2021, 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(hours.length).toEqual(12);
expect(minutes.length).toEqual(60); expect(minutes.length).toEqual(60);
@ -199,7 +320,7 @@ describe('generateTime()', () => {
minute: 43, 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(hours).toStrictEqual([1, 2, 3]);
expect(minutes).toStrictEqual([10, 15, 20]); expect(minutes).toStrictEqual([10, 15, 20]);
@ -229,7 +350,7 @@ describe('generateTime()', () => {
minute: 14, 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(am).toBe(true);
expect(pm).toBe(true); expect(pm).toBe(true);
@ -253,7 +374,7 @@ describe('generateTime()', () => {
minute: 50, minute: 50,
}; };
const { hours } = generateTime(refValue, 'h23', minParts); const { hours } = generateTime('en-US', refValue, 'h23', minParts);
expect(hours).toStrictEqual([19, 20, 21, 22, 23]); expect(hours).toStrictEqual([19, 20, 21, 22, 23]);
}); });
@ -276,7 +397,7 @@ describe('generateTime()', () => {
minute: 30, 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(hours).toStrictEqual([19, 20, 21, 22, 23]);
expect(minutes.length).toEqual(60); expect(minutes.length).toEqual(60);
@ -308,7 +429,7 @@ describe('generateTime()', () => {
minute: 40, minute: 40,
}; };
const { hours } = generateTime(refValue, 'h23', minParts, maxParts); const { hours } = generateTime('en-US', refValue, 'h23', minParts, maxParts);
expect(hours).toStrictEqual([19, 20]); expect(hours).toStrictEqual([19, 20]);
}); });
@ -330,7 +451,7 @@ describe('generateTime()', () => {
minute: 2, minute: 2,
}; };
const { minutes } = generateTime(refValue, 'h23', undefined, maxParts); const { minutes } = generateTime('en-US', refValue, 'h23', undefined, maxParts);
expect(minutes).toStrictEqual([0, 1, 2]); expect(minutes).toStrictEqual([0, 1, 2]);
}); });
@ -352,7 +473,7 @@ describe('generateTime()', () => {
minute: 2, minute: 2,
}; };
const { minutes } = generateTime(refValue, 'h23', undefined, maxParts); const { minutes } = generateTime('en-US', refValue, 'h23', undefined, maxParts);
expect(minutes.length).toEqual(60); expect(minutes.length).toEqual(60);
}); });

View File

@ -56,11 +56,17 @@ describe('getMonthAndDay()', () => {
describe('getFormattedHour()', () => { describe('getFormattedHour()', () => {
it('should only add padding if using 24 hour time', () => { it('should only add padding if using 24 hour time', () => {
expect(getFormattedHour(0, true)).toEqual('00'); expect(getFormattedHour(1, 'h11')).toEqual('1');
expect(getFormattedHour(0, false)).toEqual('12'); expect(getFormattedHour(1, 'h12')).toEqual('1');
expect(getFormattedHour(1, 'h23')).toEqual('01');
expect(getFormattedHour(1, 'h24')).toEqual('01');
});
expect(getFormattedHour(10, true)).toEqual('10'); it('should return correct hour value for hour cycle', () => {
expect(getFormattedHour(10, false)).toEqual('10'); 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, 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', () => { it('should localize the time to AM', () => {
@ -123,7 +129,7 @@ describe('getLocalizedTime', () => {
minute: 40, 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', () => { it('should avoid Chromium bug when using 12 hour time in a 24 hour locale', () => {
@ -135,7 +141,7 @@ describe('getLocalizedTime', () => {
minute: 0, 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', () => { it('should parse time-only values correctly', () => {
const datetimeParts = { const datetimeParts = {
@ -143,7 +149,7 @@ describe('getLocalizedTime', () => {
minute: 40, minute: 40,
}; };
expect(getLocalizedTime('en-US', datetimeParts, false)).toEqual('10:40 PM'); expect(getLocalizedTime('en-US', datetimeParts, 'h12')).toEqual('10:40 PM');
expect(getLocalizedTime('en-US', datetimeParts, true)).toEqual('22:40'); expect(getLocalizedTime('en-US', datetimeParts, 'h23')).toEqual('22:40');
}); });
}); });

View File

@ -1,4 +1,4 @@
import { isLeapYear, getNumDaysInMonth, is24Hour, isMonthFirstLocale } from '../utils/helpers'; import { isLeapYear, getNumDaysInMonth, is24Hour, isMonthFirstLocale, getHourCycle } from '../utils/helpers';
describe('daysInMonth()', () => { describe('daysInMonth()', () => {
it('should return correct days in month for month and year', () => { it('should return correct days in month for month and year', () => {
@ -37,14 +37,29 @@ describe('isLeapYear()', () => {
describe('is24Hour()', () => { describe('is24Hour()', () => {
it('should return true if the locale uses 24 hour time', () => { it('should return true if the locale uses 24 hour time', () => {
expect(is24Hour('en-US')).toBe(false); expect(is24Hour('h11')).toBe(false);
expect(is24Hour('en-US', 'h23')).toBe(true); expect(is24Hour('h12')).toBe(false);
expect(is24Hour('en-US', 'h12')).toBe(false); expect(is24Hour('h23')).toBe(true);
expect(is24Hour('en-US-u-hc-h23')).toBe(true); expect(is24Hour('h24')).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); 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');
}); });
}); });

View File

@ -25,5 +25,27 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const timeButton = page.locator('ion-datetime .time-body'); const timeButton = page.locator('ion-datetime .time-body');
await expect(timeButton).toHaveText('4:30 PM'); await expect(timeButton).toHaveText('4:30 PM');
}); });
test('should set the h11 hour cycle correctly', async ({ page }) => {
await page.setContent(
`
<ion-datetime hour-cycle="h11" value="2022-01-01T00:30:00"></ion-datetime>
`,
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(
`
<ion-datetime hour-cycle="h24" value="2022-01-01T00:30:00"></ion-datetime>
`,
config
);
const timeButton = page.locator('ion-datetime .time-body');
await expect(timeButton).toHaveText('24:30');
});
}); });
}); });

View File

@ -51,6 +51,14 @@
<h2>h12 Hour cycle</h2> <h2>h12 Hour cycle</h2>
<ion-datetime hour-cycle="h12"></ion-datetime> <ion-datetime hour-cycle="h12"></ion-datetime>
</div> </div>
<div class="grid-item">
<h2>h11 Hour cycle</h2>
<ion-datetime hour-cycle="h11"></ion-datetime>
</div>
<div class="grid-item">
<h2>h24 Hour cycle</h2>
<ion-datetime hour-cycle="h24"></ion-datetime>
</div>
<div class="grid-item"> <div class="grid-item">
<h2>h23 Hour cycle (Extension Tag)</h2> <h2>h23 Hour cycle (Extension Tag)</h2>
<ion-datetime locale="en-US-u-hc-h23"></ion-datetime> <ion-datetime locale="en-US-u-hc-h23"></ion-datetime>

View File

@ -1,6 +1,6 @@
import type { Mode } from '../../../interface'; import type { Mode } from '../../../interface';
import type { PickerColumnItem } from '../../picker-column-internal/picker-column-internal-interfaces'; 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 { isAfter, isBefore, isSameDay } from './comparison';
import { import {
@ -11,7 +11,7 @@ import {
getTodayLabel, getTodayLabel,
getYear, getYear,
} from './format'; } from './format';
import { getNumDaysInMonth, is24Hour } from './helpers'; import { getNumDaysInMonth, is24Hour, getHourCycle } from './helpers';
import { getNextMonth, getPreviousMonth, getInternalHourValue } from './manipulation'; 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, 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, 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]; 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]; 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, * Given a locale and a mode,
* return an array with formatted days * return an array with formatted days
@ -125,21 +135,42 @@ export const getDaysOfMonth = (month: number, year: number, firstDayOfWeek: numb
return days; 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 * Given a local, reference datetime parts and option
* max/min bound datetime parts, calculate the acceptable * max/min bound datetime parts, calculate the acceptable
* hour and minute values according to the bounds and locale. * hour and minute values according to the bounds and locale.
*/ */
export const generateTime = ( export const generateTime = (
locale: string,
refParts: DatetimeParts, refParts: DatetimeParts,
hourCycle: 'h12' | 'h23' = 'h12', hourCycle: DatetimeHourCycle = 'h12',
minParts?: DatetimeParts, minParts?: DatetimeParts,
maxParts?: DatetimeParts, maxParts?: DatetimeParts,
hourValues?: number[], hourValues?: number[],
minuteValues?: number[] minuteValues?: number[]
) => { ) => {
const use24Hour = hourCycle === 'h23'; const computedHourCycle = getHourCycle(locale, hourCycle);
let processedHours = use24Hour ? hour23 : hour12; const use24Hour = is24Hour(computedHourCycle);
let processedHours = getHourData(computedHourCycle);
let processedMinutes = minutes; let processedMinutes = minutes;
let isAMAllowed = true; let isAMAllowed = true;
let isPMAllowed = true; let isPMAllowed = true;
@ -540,16 +571,18 @@ export const getCombinedDateColumnData = (
export const getTimeColumnsData = ( export const getTimeColumnsData = (
locale: string, locale: string,
refParts: DatetimeParts, refParts: DatetimeParts,
hourCycle?: 'h23' | 'h12', hourCycle?: DatetimeHourCycle,
minParts?: DatetimeParts, minParts?: DatetimeParts,
maxParts?: DatetimeParts, maxParts?: DatetimeParts,
allowedHourValues?: number[], allowedHourValues?: number[],
allowedMinuteValues?: number[] allowedMinuteValues?: number[]
): { [key: string]: PickerColumnItem[] } => { ): { [key: string]: PickerColumnItem[] } => {
const use24Hour = is24Hour(locale, hourCycle); const computedHourCycle = getHourCycle(locale, hourCycle);
const use24Hour = is24Hour(computedHourCycle);
const { hours, minutes, am, pm } = generateTime( const { hours, minutes, am, pm } = generateTime(
locale,
refParts, refParts,
use24Hour ? 'h23' : 'h12', computedHourCycle,
minParts, minParts,
maxParts, maxParts,
allowedHourValues, allowedHourValues,
@ -558,7 +591,7 @@ export const getTimeColumnsData = (
const hoursItems = hours.map((hour) => { const hoursItems = hours.map((hour) => {
return { return {
text: getFormattedHour(hour, use24Hour), text: getFormattedHour(hour, computedHourCycle),
value: getInternalHourValue(hour, use24Hour, refParts.ampm), value: getInternalHourValue(hour, use24Hour, refParts.ampm),
}; };
}); });

View File

@ -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'; import { convertDataToISO } from './manipulation';
const getFormattedDayPeriod = (dayPeriod?: string) => { const getFormattedDayPeriod = (dayPeriod?: string) => {
@ -10,7 +11,7 @@ const getFormattedDayPeriod = (dayPeriod?: string) => {
return dayPeriod.toUpperCase(); 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<DatetimeParts, 'hour' | 'minute'> = { const timeParts: Pick<DatetimeParts, 'hour' | 'minute'> = {
hour: refParts.hour, hour: refParts.hour,
minute: refParts.minute, minute: refParts.minute,
@ -34,7 +35,7 @@ export const getLocalizedTime = (locale: string, refParts: DatetimeParts, use24H
* We use hourCycle here instead of hour12 due to: * We use hourCycle here instead of hour12 due to:
* https://bugs.chromium.org/p/chromium/issues/detail?id=1347316&q=hour12&can=2 * 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 * Setting Z at the end indicates that this
* date string is in the UTC time zone. 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 * 12 hour times it ensures that
* hour 0 is formatted as '12'. * hour 0 is formatted as '12'.
*/ */
export const getFormattedHour = (hour: number, use24Hour: boolean): string => { export const getFormattedHour = (hour: number, hourCycle: DatetimeHourCycle): string => {
if (use24Hour) {
return addTimePadding(hour);
}
/** /**
* If using 12 hour * Midnight for h11 starts at 0:00am
* format, make sure hour * Midnight for h12 starts at 12:00am
* 0 is formatted as '12'. * Midnight for h23 starts at 00:00
* Midnight for h24 starts at 24:00
*/ */
if (hour === 0) { 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(); return hour.toString();

View File

@ -1,3 +1,5 @@
import type { DatetimeHourCycle } from '../datetime-interface';
/** /**
* Determines if given year is a * Determines if given year is a
* leap year. Returns `true` if year * 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; 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. * then return early and do not look at the system default.
*/ */
if (hourCycle !== undefined) { 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 formatted = new Intl.DateTimeFormat(locale, { hour: 'numeric' });
const options = formatted.resolvedOptions(); const options = formatted.resolvedOptions();
if (options.hourCycle !== undefined) { 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'); 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';
}; };
/** /**