diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts b/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts index 7240b09c15d..02aeb7508d8 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts @@ -1,7 +1,14 @@ // @ts-strict-ignore import { newE2EPage, E2EPage, E2EElement } from "@arcgis/lumina-compiler/puppeteerTesting"; import { describe, expect, it, beforeEach } from "vitest"; -import { localizeTimeString } from "../../utils/time"; +import { + getLocaleHourFormat, + getLocalizedDecimalSeparator, + getLocalizedMeridiem, + getLocalizedTimePartSuffix, + getMeridiemOrder, + localizeTimeString, +} from "../../utils/time"; import { accessible, defaults, @@ -14,41 +21,35 @@ import { renders, t9n, } from "../../tests/commonTests"; -import { getFocusedElementProp, isElementFocused, skipAnimations, waitForAnimationFrame } from "../../tests/utils"; +import { + getFocusedElementProp, + isElementFocused, + selectText, + skipAnimations, + waitForAnimationFrame, +} from "../../tests/utils"; import { html } from "../../../support/formatting"; import { openClose } from "../../tests/commonTests"; +import { supportedLocales } from "../../utils/locale"; import { CSS as PopoverCSS } from "../popover/resources"; async function getInputValue(page: E2EPage): Promise { return page.evaluate( - () => - document - .querySelector("calcite-input-time-picker") - .shadowRoot.querySelector("calcite-input-text") - .shadowRoot.querySelector("input").value, + () => document.querySelector("calcite-input-time-picker").shadowRoot.querySelector("calcite-input-text").value, ); } describe("calcite-input-time-picker", () => { describe("renders", () => { renders("calcite-input-time-picker", { display: "inline-block" }); - }); - - describe("renders with en-us lowercase locale code", () => { - renders(``, { display: "inline-block" }); - }); - - describe("renders with base lang when region code is unsupported", () => { - renders(``, { display: "inline-block" }); - }); - describe("renders with pt-PT locale", () => { - renders(``, { display: "inline-block" }); - }); + describe("renders with en-us lowercase locale code", () => { + renders(``, { display: "inline-block" }); + }); - // TODO: restore once "nb" ➡️ "no" is supported by useT9n - describe.skip("renders with no locale", () => { - renders(``, { display: "inline-block" }); + describe("renders with base lang when region code is unsupported", () => { + renders(``, { display: "inline-block" }); + }); }); describe("honors hidden attribute", () => { @@ -93,7 +94,7 @@ describe("calcite-input-time-picker", () => { labelable("calcite-input-time-picker"); }); - describe("should focus the input when setFocus is called", () => { + describe("focusable", () => { focusable(`calcite-input-time-picker`, { shadowFocusTargetSelector: "calcite-input-text", }); @@ -103,503 +104,294 @@ describe("calcite-input-time-picker", () => { disabled("calcite-input-time-picker"); }); - describe("openClose", () => { - openClose("calcite-input-time-picker"); - - describe.skip("initially open", () => { - openClose.initial("calcite-input-time-picker"); - }); - }); - - it("when set to readOnly, element still focusable but won't display the controls or allow for changing the value", async () => { + it("resets initial value to empty when it is not a valid time value", async () => { const page = await newE2EPage(); - await page.setContent( - ``, - ); - - const component = await page.find("#canReadOnly"); - const input = await page.find("#canReadOnly >>> calcite-input-text"); - const popover = await page.find("#canReadOnly >>> calcite-popover"); - - expect(await input.getProperty("value")).toBe(""); - - await component.click(); - await page.waitForChanges(); - - expect(await page.evaluate(() => document.activeElement.id)).toBe("canReadOnly"); - expect(await popover.getProperty("open")).toBe(false); - - await component.click(); - await page.waitForChanges(); - expect(await popover.getProperty("open")).toBe(false); - - await component.type("atención atención"); - await page.waitForChanges(); - - expect(await input.getProperty("value")).toBe(""); - }); - - it("directly changing the value reflects in the input for 24-hour (french lang)", async () => { - const locale = "fr"; - const numberingSystem = "latn"; - - const page = await newE2EPage(); - await page.setContent( - ``, - ); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - - for (let second = 0; second < 10; second++) { - const date = new Date(0); - date.setSeconds(second); - - const expectedValue = date.toISOString().substr(11, 8); - const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - - inputTimePicker.setProperty("value", expectedValue); - - await page.waitForChanges(); - - const inputValue = await input.getProperty("value"); - const inputTimePickerValue = await inputTimePicker.getProperty("value"); - - expect(inputValue).toBe(expectedInputValue); - expect(inputTimePickerValue).toBe(expectedValue); - } - - for (let minute = 0; minute < 10; minute++) { - const date = new Date(0); - date.setMinutes(minute); - - const expectedValue = date.toISOString().substr(11, 8); - const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - - inputTimePicker.setProperty("value", expectedValue); - - await page.waitForChanges(); - - const inputValue = await input.getProperty("value"); - const inputTimePickerValue = await inputTimePicker.getProperty("value"); - - expect(inputValue).toBe(expectedInputValue); - expect(inputTimePickerValue).toBe(expectedValue); - } - - for (let hour = 0; hour < 10; hour++) { - const date = new Date(0); - date.setHours(hour); - - const expectedValue = date.toISOString().substr(11, 8); - const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - - inputTimePicker.setProperty("value", expectedValue); - - await page.waitForChanges(); - - const inputValue = await input.getProperty("value"); - const inputTimePickerValue = await inputTimePicker.getProperty("value"); - - expect(inputValue).toBe(expectedInputValue); - expect(inputTimePickerValue).toBe(expectedValue); - } - }); - - it("value displays correctly in the input when it is directly changed for a 12-hour language when a default value is present", async () => { - const locale = "en"; - const numberingSystem = "latn"; - - const page = await newE2EPage(); - await page.setContent( - ``, - ); + await page.setContent(``); const inputTimePicker = await page.find("calcite-input-time-picker"); - const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - expect(await input.getProperty("value")).toBe("11:00:00 AM"); - expect(await inputTimePicker.getProperty("value")).toBe("11:00:00"); - - const date = new Date(0); - date.setHours(13); - date.setMinutes(59); - date.setSeconds(59); - - const expectedValue = date.toISOString().substr(11, 8); - const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - - inputTimePicker.setProperty("value", expectedValue); - - await page.waitForChanges(); - - const inputValue = await input.getProperty("value"); - const inputTimePickerValue = await inputTimePicker.getProperty("value"); - - expect(inputValue).toBe(expectedInputValue); - expect(inputTimePickerValue).toBe(expectedValue); + expect(await inputTimePicker.getProperty("value")).toBe(""); }); - it("value displays correctly in the input when it is directly changed for a 24-hour language when a default value is present (arab lang/numberingSystem)", async () => { - const locale = "ar"; - const numberingSystem = "arab"; - const initialValue = "11:00:00"; - - const page = await newE2EPage(); - await page.setContent( - ``, - ); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - - const initialDisplayValue = localizeTimeString({ value: initialValue, locale, numberingSystem }); - expect(await input.getProperty("value")).toBe(initialDisplayValue); - expect(await inputTimePicker.getProperty("value")).toBe(initialValue); - - const date = new Date(0); - date.setHours(13); - date.setMinutes(59); - date.setSeconds(59); - - const expectedValue = "13:59:59"; - const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - - inputTimePicker.setProperty("value", expectedValue); - - await page.waitForChanges(); - - const inputValue = await input.getProperty("value"); - const inputTimePickerValue = await inputTimePicker.getProperty("value"); + describe("openClose", () => { + openClose("calcite-input-time-picker"); - expect(inputValue).toBe(expectedInputValue); - expect(inputTimePickerValue).toBe(expectedValue); + describe.skip("initially open", () => { + openClose.initial("calcite-input-time-picker"); + }); }); - it("committing hh:mm value when step=60 with the Enter key works as expected for a 12-hour locale", async () => { + it("resets to previous value when default event behavior is prevented", async () => { const page = await newE2EPage(); - await page.setContent(``); + await page.setContent(``); const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - - expect(changeEvent).toHaveReceivedEventTimes(0); - - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.type("5:4 PM"); - await page.waitForChanges(); - - expect(changeEvent).toHaveReceivedEventTimes(0); - - await page.keyboard.press("Enter"); - await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("17:04"); - expect(await getInputValue(page)).toBe("05:04 PM"); - - await page.keyboard.press("Enter"); - await page.waitForChanges(); - - expect(changeEvent).toHaveReceivedEventTimes(1); - }); - - it("committing hh:mm value when step=60 with the Enter key works as expected for a 24-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + await page.evaluate(() => { + const inputTimePicker = document.querySelector("calcite-input-time-picker"); + inputTimePicker.addEventListener("calciteInputTimePickerChange", (event) => { + event.preventDefault(); + }); + }); - expect(changeEvent).toHaveReceivedEventTimes(0); + expect(await inputTimePicker.getProperty("value")).toBe("14:59"); await inputTimePicker.callMethod("setFocus"); await page.waitForChanges(); - await page.keyboard.type("5:4"); - await page.waitForChanges(); - - expect(changeEvent).toHaveReceivedEventTimes(0); - - await page.keyboard.press("Enter"); - await page.waitForChanges(); - - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("05:04"); - expect(await getInputValue(page)).toBe("05:04"); - + await page.keyboard.press("Backspace"); + await page.keyboard.press("5"); await page.keyboard.press("Enter"); await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await inputTimePicker.getProperty("value")).toBe("14:59"); }); - it("committing hh:mm:ss value when step=60 with the Enter key works as expected for a 12-hour locale", async () => { + it("when set to readOnly, element still focusable but won't display the controls or allow for changing the value", async () => { const page = await newE2EPage(); - await page.setContent(``); + await page.setContent( + ``, + ); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + const component = await page.find("#canReadOnly"); + const input = await page.find("#canReadOnly >>> calcite-input-text"); + const popover = await page.find("#canReadOnly >>> calcite-popover"); - expect(changeEvent).toHaveReceivedEventTimes(0); + expect(await input.getProperty("value")).toBe(""); - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.type("5:4:3 PM"); + await component.click(); await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(0); + expect(await page.evaluate(() => document.activeElement.id)).toBe("canReadOnly"); + expect(await popover.getProperty("open")).toBe(false); - await page.keyboard.press("Enter"); + await component.click(); await page.waitForChanges(); + expect(await popover.getProperty("open")).toBe(false); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("17:04"); - expect(await getInputValue(page)).toBe("05:04 PM"); - - await page.keyboard.press("Enter"); + await component.type("attention attention"); await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await input.getProperty("value")).toBe(""); }); - it("committing hh:mm:ss value when step=60 with the Enter key works as expected for a 24-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + describe("direct value setting", () => { + it("directly changing the value reflects in the input for 24-hour (french lang)", async () => { + const locale = "fr"; + const numberingSystem = "latn"; - expect(changeEvent).toHaveReceivedEventTimes(0); + const page = await newE2EPage(); + await page.setContent( + ``, + ); - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.type("5:4:3"); - await page.waitForChanges(); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - expect(changeEvent).toHaveReceivedEventTimes(0); + for (let second = 0; second < 10; second++) { + const date = new Date(0); + date.setSeconds(second); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + const expectedValue = date.toISOString().substr(11, 8); + const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("05:04"); - expect(await getInputValue(page)).toBe("05:04"); + inputTimePicker.setProperty("value", expectedValue); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(1); - }); + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); - it("committing hh:mm:ss value when step=1 with the Enter key works as expected for a 12-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); + expect(inputValue).toBe(expectedInputValue); + expect(inputTimePickerValue).toBe(expectedValue); + } - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + for (let minute = 0; minute < 10; minute++) { + const date = new Date(0); + date.setMinutes(minute); - expect(changeEvent).toHaveReceivedEventTimes(0); + const expectedValue = date.toISOString().substr(11, 8); + const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - await page.keyboard.press("Tab"); - await page.keyboard.type("5:4:3 PM"); - await page.waitForChanges(); + inputTimePicker.setProperty("value", expectedValue); - expect(changeEvent).toHaveReceivedEventTimes(0); + await page.waitForChanges(); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("17:04:03"); - expect(await getInputValue(page)).toBe("05:04:03 PM"); + expect(inputValue).toBe(expectedInputValue); + expect(inputTimePickerValue).toBe(expectedValue); + } - await page.keyboard.press("Enter"); - await page.waitForChanges(); + for (let hour = 0; hour < 10; hour++) { + const date = new Date(0); + date.setHours(hour); - expect(changeEvent).toHaveReceivedEventTimes(1); - }); + const expectedValue = date.toISOString().substr(11, 8); + const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - it("committing hh:mm:ss value when step=1 with the Enter key works as expected for a 24-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); + inputTimePicker.setProperty("value", expectedValue); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - - expect(changeEvent).toHaveReceivedEventTimes(0); - - await page.keyboard.press("Tab"); - await page.keyboard.type("14:2:3"); - await page.waitForChanges(); + await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(0); - - await page.keyboard.press("Enter"); - await page.waitForChanges(); + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("14:02:03"); - expect(await getInputValue(page)).toBe("14:02:03"); + expect(inputValue).toBe(expectedInputValue); + expect(inputTimePickerValue).toBe(expectedValue); + } + }); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + it("value displays correctly in the input when it is directly changed for a 12-hour language when a default value is present", async () => { + const locale = "en"; + const numberingSystem = "latn"; - expect(changeEvent).toHaveReceivedEventTimes(1); - }); + const page = await newE2EPage(); + await page.setContent( + ``, + ); - it("committing hh:mm value when step=1 with the Enter key works as expected for a 12-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + expect(await input.getProperty("value")).toBe("11:00:00 AM"); + expect(await inputTimePicker.getProperty("value")).toBe("11:00:00"); - expect(changeEvent).toHaveReceivedEventTimes(0); + const date = new Date(0); + date.setHours(13); + date.setMinutes(59); + date.setSeconds(59); - await page.keyboard.press("Tab"); - await page.keyboard.type("5:4 PM"); - await page.waitForChanges(); + const expectedValue = date.toISOString().substr(11, 8); + const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - expect(changeEvent).toHaveReceivedEventTimes(0); + inputTimePicker.setProperty("value", expectedValue); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("17:04:00"); - expect(await getInputValue(page)).toBe("05:04:00 PM"); + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + expect(inputValue).toBe(expectedInputValue); + expect(inputTimePickerValue).toBe(expectedValue); + }); - expect(changeEvent).toHaveReceivedEventTimes(1); - }); + it("value displays correctly in the input when it is directly changed for a 24-hour language when a default value is present (arab lang/numberingSystem)", async () => { + const locale = "ar"; + const numberingSystem = "arab"; + const initialValue = "11:00:00"; - it("committing hh:mm value when step=1 with the Enter key works as expected for a 24-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); + const page = await newE2EPage(); + await page.setContent( + ``, + ); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - expect(changeEvent).toHaveReceivedEventTimes(0); + const initialDisplayValue = localizeTimeString({ value: initialValue, locale, numberingSystem }); + expect(await input.getProperty("value")).toBe(initialDisplayValue); + expect(await inputTimePicker.getProperty("value")).toBe(initialValue); - await page.keyboard.press("Tab"); - await page.keyboard.type("14:2"); - await page.waitForChanges(); + const date = new Date(0); + date.setHours(13); + date.setMinutes(59); + date.setSeconds(59); - expect(changeEvent).toHaveReceivedEventTimes(0); + const expectedValue = "13:59:59"; + const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + inputTimePicker.setProperty("value", expectedValue); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("14:02:00"); - expect(await getInputValue(page)).toBe("14:02:00"); + await page.waitForChanges(); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); - expect(changeEvent).toHaveReceivedEventTimes(1); + expect(inputValue).toBe(expectedInputValue); + expect(inputTimePickerValue).toBe(expectedValue); + }); }); - it("attempting to commit an invalid time value fails, but leaves the typed value intact", async () => { - const page = await newE2EPage(); - await page.setContent(``); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - - expect(changeEvent).toHaveReceivedEventTimes(0); - - await page.keyboard.press("Tab"); - await page.keyboard.type("26:0:0"); - await page.waitForChanges(); - - expect(changeEvent).toHaveReceivedEventTimes(0); - - await page.keyboard.press("Enter"); - await page.waitForChanges(); - - expect(changeEvent).toHaveReceivedEventTimes(0); - expect(await inputTimePicker.getProperty("value")).toBe(""); - expect(await getInputValue(page)).toBe("26:0:0"); + describe("committing values with the keyboard", () => { + it("attempting to commit an invalid time value with the Enter key fails, but leaves the typed value intact", async () => { + const page = await newE2EPage(); + await page.setContent(``); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - expect(changeEvent).toHaveReceivedEventTimes(0); + expect(changeEvent).toHaveReceivedEventTimes(0); - await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.type("foo bar"); + await page.waitForChanges(); - expect(changeEvent).toHaveReceivedEventTimes(0); - expect(await inputTimePicker.getProperty("value")).toBe(""); - expect(await getInputValue(page)).toBe("26:0:0"); - }); + expect(changeEvent).toHaveReceivedEventTimes(0); - it("blurring the input with a valid time commits the value for 12-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); + await page.keyboard.press("Enter"); + await page.waitForChanges(); - const inputTimePicker = await page.find("calcite-input-time-picker"); + expect(changeEvent).toHaveReceivedEventTimes(0); + expect(await inputTimePicker.getProperty("value")).toBe(""); + expect(await getInputValue(page)).toBe("foo bar"); - await page.keyboard.press("Tab"); - await page.keyboard.type("2:3:4 PM"); - await page.keyboard.press("Tab"); - await page.waitForChanges(); + await page.keyboard.press("Enter"); + await page.waitForChanges(); - expect(await inputTimePicker.getProperty("value")).toBe("14:03:04"); - expect(await getInputValue(page)).toBe("02:03:04 PM"); - }); + expect(changeEvent).toHaveReceivedEventTimes(0); - it("blurring the input with a valid time commits the value for 24-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); + await page.keyboard.press("Tab"); - const inputTimePicker = await page.find("calcite-input-time-picker"); + expect(changeEvent).toHaveReceivedEventTimes(0); + expect(await inputTimePicker.getProperty("value")).toBe(""); + expect(await getInputValue(page)).toBe("foo bar"); + }); - await page.keyboard.press("Tab"); - await page.keyboard.type("2:3:4"); - await page.keyboard.press("Tab"); - await page.waitForChanges(); + it("pressing enter on a cleared input sets the value to empty string", async () => { + const page = await newE2EPage(); + await page.setContent(``); - expect(await getInputValue(page)).toBe("02:03:04"); - expect(await inputTimePicker.getProperty("value")).toBe("02:03:04"); - }); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - it("resets to previous value when default event behavior is prevented", async () => { - const page = await newE2EPage(); - await page.setContent(``); + expect(changeEvent).toHaveReceivedEventTimes(0); - const inputTimePicker = await page.find("calcite-input-time-picker"); + await inputTimePicker.callMethod("setFocus"); + await selectText(inputTimePicker); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Enter"); + await page.waitForChanges(); - await page.evaluate(() => { - const inputTimePicker = document.querySelector("calcite-input-time-picker"); - inputTimePicker.addEventListener("calciteInputTimePickerChange", (event) => { - event.preventDefault(); - }); + expect(await getInputValue(page)).toBe(""); + expect(await inputTimePicker.getProperty("value")).toBe(""); + expect(changeEvent).toHaveReceivedEventTimes(1); }); - expect(await inputTimePicker.getProperty("value")).toBe("14:59"); - - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.press("Backspace"); - await page.keyboard.press("5"); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + it("allows editing just a portion of the time value in the input for a 12-hour locale", async () => { + const page = await newE2EPage(); + await page.setContent(``); - expect(await inputTimePicker.getProperty("value")).toBe("14:59"); - }); + const inputTimePicker = await page.find("calcite-input-time-picker"); + await inputTimePicker.callMethod("setFocus"); + await page.waitForChanges(); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("Backspace"); + await page.keyboard.press("5"); - it("empties initial value when it is not a valid time value", async () => { - const page = await newE2EPage(); - await page.setContent(``); + expect(await getInputValue(page)).toBe("02:05:00 PM"); + expect(await inputTimePicker.getProperty("value")).toBe("14:00:00"); - const inputTimePicker = await page.find("calcite-input-time-picker"); + await page.keyboard.press("Enter"); + await page.waitForChanges(); - expect(await inputTimePicker.getProperty("value")).toBe(""); + expect(await inputTimePicker.getProperty("value")).toBe("14:05:00"); + }); }); describe("is form-associated", () => { @@ -612,255 +404,370 @@ describe("calcite-input-time-picker", () => { }); }); - it("updates value appropriately as step changes", async () => { - const page = await newE2EPage(); - await page.setContent(``); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - - expect(await inputTimePicker.getProperty("value")).toBe("01:02"); - expect(await getInputValue(page)).toBe("01:02 AM"); - - inputTimePicker.setProperty("step", 1); - await page.waitForChanges(); - - expect(await inputTimePicker.getProperty("value")).toBe("01:02:00"); - expect(await getInputValue(page)).toBe("01:02:00 AM"); - - inputTimePicker.setProperty("step", 60); - await page.waitForChanges(); - - expect(await inputTimePicker.getProperty("value")).toBe("01:02"); - expect(await getInputValue(page)).toBe("01:02 AM"); - }); - - it("allows editing just a portion of the time value in the input for a 12-hour locale", async () => { - const page = await newE2EPage(); - await page.setContent(``); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.press("ArrowLeft"); - await page.keyboard.press("ArrowLeft"); - await page.keyboard.press("ArrowLeft"); - await page.keyboard.press("ArrowLeft"); - await page.keyboard.press("ArrowLeft"); - await page.keyboard.press("ArrowLeft"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("5"); - - expect(await getInputValue(page)).toBe("02:05:00 PM"); - expect(await inputTimePicker.getProperty("value")).toBe("14:00:00"); - - await page.keyboard.press("Enter"); - - expect(await inputTimePicker.getProperty("value")).toBe("14:05:00"); - }); - - it("correctly relocalizes the display value when the lang and numbering systems change", async () => { - const page = await newE2EPage(); - await page.setContent(``); - const inputTimePicker = await page.find("calcite-input-time-picker"); - - expect(await getInputValue(page)).toBe("02:30:25 PM"); - - inputTimePicker.setProperty("lang", "da"); - await page.waitForChanges(); - // waiting for an additional animation frame here allows for mutation observers and other things outside of Stencil's knowledge to complete before the page is ready to test - await waitForAnimationFrame(); - - expect(await getInputValue(page)).toBe("14.30.25"); + describe("responds to property changes", () => { + it("updates value appropriately as step changes", async () => { + const page = await newE2EPage(); + await page.setContent(``); - inputTimePicker.setProperty("lang", "ar"); - await page.waitForChanges(); - await waitForAnimationFrame(); + const inputTimePicker = await page.find("calcite-input-time-picker"); - expect(await getInputValue(page)).toBe("02:30:25 م"); + expect(await inputTimePicker.getProperty("value")).toBe("01:02"); + expect(await getInputValue(page)).toBe("01:02 AM"); - inputTimePicker.setProperty("numberingSystem", "arab"); - await page.waitForChanges(); - await waitForAnimationFrame(); + inputTimePicker.setProperty("step", 1); + await page.waitForChanges(); - expect(await getInputValue(page)).toBe("٠٢:٣٠:٢٥ م"); + expect(await inputTimePicker.getProperty("value")).toBe("01:02:00"); + expect(await getInputValue(page)).toBe("01:02:00 AM"); - inputTimePicker.setProperty("lang", "zh-HK"); - inputTimePicker.setProperty("numberingSystem", "latn"); - await page.waitForChanges(); - await waitForAnimationFrame(); + inputTimePicker.setProperty("step", 60); + await page.waitForChanges(); - expect(await getInputValue(page)).toBe("下午02:30:25"); - }); + expect(await inputTimePicker.getProperty("value")).toBe("01:02"); + expect(await getInputValue(page)).toBe("01:02 AM"); + }); - describe("arabic locale support", () => { - it("localizes initial display value in arab numbering system", async () => { + it("correctly relocalizes the display value when the lang and numbering systems change", async () => { const page = await newE2EPage(); - await page.setContent( - ``, - ); - + await page.setContent(``); const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - expect(changeEvent).toHaveReceivedEventTimes(0); - expect(await getInputValue(page)).toBe("٠٢:٠٢:٣٠ م"); - expect(await inputTimePicker.getProperty("value")).toBe("14:02:30"); - }); + expect(await getInputValue(page)).toBe("02:30:25 PM"); - it("converts latn numbers to arab while typing", async () => { - const page = await newE2EPage(); - await page.setContent( - ``, - ); + inputTimePicker.setProperty("lang", "da"); + await page.waitForChanges(); + // waiting for an additional animation frame here allows for mutation observers and other things outside of Stencil's knowledge to complete before the page is ready to test + await waitForAnimationFrame(); - const inputTimePicker = await page.find("calcite-input-time-picker"); + expect(await getInputValue(page)).toBe("14.30.25"); - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.type("0123456789"); + inputTimePicker.setProperty("lang", "ar"); await page.waitForChanges(); + await waitForAnimationFrame(); - expect(await getInputValue(page)).toBe("٠١٢٣٤٥٦٧٨٩"); - }); + expect(await getInputValue(page)).toBe("02:30:25 م"); - it("committing typed value works as expected in arab numbering system", async () => { - const page = await newE2EPage(); - await page.setContent( - ``, - ); + inputTimePicker.setProperty("numberingSystem", "arab"); + await page.waitForChanges(); + await waitForAnimationFrame(); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + expect(await getInputValue(page)).toBe("٠٢:٣٠:٢٥ م"); - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.type("2:45:30 م"); - await page.keyboard.press("Enter"); + inputTimePicker.setProperty("lang", "zh-HK"); + inputTimePicker.setProperty("numberingSystem", "latn"); await page.waitForChanges(); + await waitForAnimationFrame(); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await getInputValue(page)).toBe("٠٢:٤٥:٣٠ م"); - expect(await inputTimePicker.getProperty("value")).toBe("14:45:30"); + expect(await getInputValue(page)).toBe("下午02:30:25"); }); + }); - it("value displays correctly in the input when it is directly changed for arabic lang and arab numberingSystem", async () => { - const locale = "ar"; - const numberingSystem = "arab"; - - const page = await newE2EPage(); - await page.setContent( - ``, - ); - - const inputTimePicker = await page.find("calcite-input-time-picker"); - const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - - const date = new Date(0); - date.setHours(13); - date.setMinutes(59); - date.setSeconds(59); + describe("l10n", () => { + describe("arabic", () => { + it("localizes initial display value in arab numbering system", async () => { + const page = await newE2EPage(); + await page.setContent( + ``, + ); - const expectedValue = date.toISOString().substr(11, 8); - const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - inputTimePicker.setProperty("value", expectedValue); + expect(changeEvent).toHaveReceivedEventTimes(0); + expect(await getInputValue(page)).toBe("٠٢:٠٢:٣٠ م"); + expect(await inputTimePicker.getProperty("value")).toBe("14:02:30"); + }); - await page.waitForChanges(); + it("converts latn numbers to arab while typing", async () => { + const page = await newE2EPage(); + await page.setContent( + ``, + ); - const inputValue = await input.getProperty("value"); - const inputTimePickerValue = await inputTimePicker.getProperty("value"); + const inputTimePicker = await page.find("calcite-input-time-picker"); - expect(inputValue).toBe(expectedInputValue); - expect(inputTimePickerValue).toBe(expectedValue); - }); - }); + await inputTimePicker.callMethod("setFocus"); + await page.waitForChanges(); + await page.keyboard.type("0123456789"); - describe("danish locale support", () => { - it("localizes initial display value", async () => { - const page = await newE2EPage(); - await page.setContent( - ``, - ); + expect(await getInputValue(page)).toBe("٠١٢٣٤٥٦٧٨٩"); + }); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + it("committing typed value works as expected in arab numbering system", async () => { + const page = await newE2EPage(); + await page.setContent( + ``, + ); - expect(changeEvent).toHaveReceivedEventTimes(0); - expect(await getInputValue(page)).toBe("14.02.30"); - expect(await inputTimePicker.getProperty("value")).toBe("14:02:30"); - }); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); - it("allows typing in danish format", async () => { - const page = await newE2EPage(); - await page.setContent(``); + await inputTimePicker.callMethod("setFocus"); + await page.waitForChanges(); + await page.keyboard.type("2:45:30 م"); + await page.keyboard.press("Enter"); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await getInputValue(page)).toBe("٠٢:٤٥:٣٠ م"); + expect(await inputTimePicker.getProperty("value")).toBe("14:45:30"); + }); - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.type("1.2.3"); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + it("value displays correctly in the input when it is directly changed for arabic lang and arab numberingSystem", async () => { + const locale = "ar"; + const numberingSystem = "arab"; - expect(await getInputValue(page)).toBe("01.02.03"); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("01:02:03"); + const page = await newE2EPage(); + await page.setContent( + ``, + ); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.type("16.30.13"); - await page.keyboard.press("Tab"); - await page.waitForChanges(); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const input = await page.find("calcite-input-time-picker >>> calcite-input-text"); - expect(changeEvent).toHaveReceivedEventTimes(2); - expect(await inputTimePicker.getProperty("value")).toBe("16:30:13"); - expect(await getInputValue(page)).toBe("16.30.13"); - }); - }); + const date = new Date(0); + date.setHours(13); + date.setMinutes(59); + date.setSeconds(59); - describe("australian english locale support", () => { - it("allows typing in australian english format", async () => { - const page = await newE2EPage(); - await page.setContent(``); + const expectedValue = date.toISOString().substr(11, 8); + const expectedInputValue = localizeTimeString({ value: expectedValue, locale, numberingSystem }); - const inputTimePicker = await page.find("calcite-input-time-picker"); - const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + inputTimePicker.setProperty("value", expectedValue); - await inputTimePicker.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.type("2:3:5 am"); - await page.keyboard.press("Enter"); - await page.waitForChanges(); + await page.waitForChanges(); - expect(await getInputValue(page)).toBe("02:03:05 am"); - expect(changeEvent).toHaveReceivedEventTimes(1); - expect(await inputTimePicker.getProperty("value")).toBe("02:03:05"); + const inputValue = await input.getProperty("value"); + const inputTimePickerValue = await inputTimePicker.getProperty("value"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.press("Backspace"); - await page.keyboard.type("4:3:5 pm"); - await page.keyboard.press("Tab"); - await page.waitForChanges(); + expect(inputValue).toBe(expectedInputValue); + expect(inputTimePickerValue).toBe(expectedValue); + }); + }); - expect(await getInputValue(page)).toBe("04:03:05 pm"); - expect(await inputTimePicker.getProperty("value")).toBe("16:03:05"); - expect(changeEvent).toHaveReceivedEventTimes(2); + supportedLocales.forEach((locale) => { + const localizedHourSuffix = getLocalizedTimePartSuffix("hour", locale); + const localizedMinuteSuffix = getLocalizedTimePartSuffix("minute", locale); + const localizedSecondSuffix = getLocalizedTimePartSuffix("second", locale); + const localizedDecimalSeparator = getLocalizedDecimalSeparator(locale, "latn"); + const localeHourFormat = getLocaleHourFormat(locale); + + describe(`${locale} (${localeHourFormat}-hour)`, () => { + it(`uses the locale's preferred setting when hour-format="user"`, async () => { + const initialDelocalizedValue = "14:02:30.001"; + const page = await newE2EPage(); + await page.setContent(html` + + + `); + + const expectedLocalizedInitialValue = localizeTimeString({ + fractionalSecondDigits: 3, + includeSeconds: true, + locale, + value: initialDelocalizedValue, + }); + + expect(initialDelocalizedValue).toBe("14:02:30.001"); + expect(await getInputValue(page)).toBe(expectedLocalizedInitialValue); + }); + + it("supports localized 12-hour format", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + `); + + const input = await page.find("input"); + const inputTimePicker = await page.find("calcite-input-time-picker"); + const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + + expect(changeEvent).toHaveReceivedEventTimes(0); + + const initialDelocalizedValue = await inputTimePicker.getProperty("value"); + const expectedLocalizedInitialValue = localizeTimeString({ + fractionalSecondDigits: 3, + hour12: true, + includeSeconds: true, + locale, + value: initialDelocalizedValue, + }); + + expect(initialDelocalizedValue).toBe("14:02:30.001"); + expect(await getInputValue(page)).toBe(expectedLocalizedInitialValue); + + await selectText(inputTimePicker); + await page.keyboard.press("Backspace"); + + const meridiemOrder = getMeridiemOrder(locale); + const localizedMeridiemToType = getLocalizedMeridiem(locale, "PM"); + + let localizedTimeToType = `2${localizedHourSuffix}30${localizedMinuteSuffix}45${localizedDecimalSeparator}002`; + if (localizedSecondSuffix) { + localizedTimeToType += localizedSecondSuffix; + } + let valueToType = + meridiemOrder === 0 + ? `${localizedMeridiemToType} ${localizedTimeToType}` + : `${localizedTimeToType} ${localizedMeridiemToType}`; + + await page.keyboard.type(valueToType); + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + + const delocalizedValue = await inputTimePicker.getProperty("value"); + const expectedLocalizedValue = localizeTimeString({ + fractionalSecondDigits: 3, + hour12: true, + includeSeconds: true, + locale, + value: delocalizedValue, + }); + + expect(delocalizedValue).toBe("14:30:45.002"); + expect(await getInputValue(page)).toBe(expectedLocalizedValue); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + + await selectText(inputTimePicker); + await page.keyboard.press("Backspace"); + + localizedTimeToType = `4${localizedHourSuffix}15${localizedMinuteSuffix}30${localizedDecimalSeparator}003`; + if (localizedSecondSuffix) { + localizedTimeToType += localizedSecondSuffix; + } + valueToType = + meridiemOrder === 0 + ? `${localizedMeridiemToType} ${localizedTimeToType}` + : `${localizedTimeToType} ${localizedMeridiemToType}`; + + await page.keyboard.type(valueToType); + await input.focus(); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(2); + + const delocalizedValueAfterBlur = await inputTimePicker.getProperty("value"); + const expectedLocalizedValueAfterBlur = localizeTimeString({ + fractionalSecondDigits: 3, + hour12: true, + includeSeconds: true, + locale, + value: delocalizedValueAfterBlur, + }); + + expect(delocalizedValueAfterBlur).toBe("16:15:30.003"); + expect(await getInputValue(page)).toBe(expectedLocalizedValueAfterBlur); + + await inputTimePicker.setProperty("hourFormat", "24"); + await page.waitForChanges(); + + expect(await getInputValue(page)).toBe( + localizeTimeString({ + fractionalSecondDigits: 3, + hour12: false, + includeSeconds: true, + locale, + value: delocalizedValueAfterBlur, + }), + ); + }); + + it("supports localized 24-hour format", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + `); + + const inputTimePicker = await page.find("calcite-input-time-picker"); + const changeEvent = await inputTimePicker.spyOnEvent("calciteInputTimePickerChange"); + const initialDelocalizedValue = await inputTimePicker.getProperty("value"); + const initialLocalizedInputValue = await getInputValue(page); + const expectedInitialLocalizedInputValue = localizeTimeString({ + fractionalSecondDigits: 3, + hour12: false, + includeSeconds: true, + locale, + value: initialDelocalizedValue, + }); + + expect(changeEvent).toHaveReceivedEventTimes(0); + expect(initialDelocalizedValue).toBe("14:02:30.001"); + expect(initialLocalizedInputValue).toBe(expectedInitialLocalizedInputValue); + + let localizedValueToType = `14${localizedHourSuffix}30${localizedMinuteSuffix}45${localizedDecimalSeparator}002`; + if (localizedSecondSuffix) { + localizedValueToType += localizedSecondSuffix; + } + + await selectText(inputTimePicker); + await page.keyboard.press("Backspace"); + await page.keyboard.type(localizedValueToType); + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await inputTimePicker.getProperty("value")).toBe("14:30:45.002"); + expect(await getInputValue(page)).toBe(localizedValueToType); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + + localizedValueToType = `16${localizedHourSuffix}15${localizedMinuteSuffix}30${localizedDecimalSeparator}003`; + if (localizedSecondSuffix) { + localizedValueToType += localizedSecondSuffix; + } + + await selectText(inputTimePicker); + await page.keyboard.press("Backspace"); + await page.keyboard.type(localizedValueToType); + + const input = await page.find("input"); + await input.focus(); + + expect(changeEvent).toHaveReceivedEventTimes(2); + expect(await inputTimePicker.getProperty("value")).toBe("16:15:30.003"); + expect(await getInputValue(page)).toBe(localizedValueToType); + + await inputTimePicker.setProperty("hourFormat", "12"); + await page.waitForChanges(); + + const expectedInputValue = localizeTimeString({ + fractionalSecondDigits: 3, + hour12: true, + includeSeconds: true, + locale, + value: await inputTimePicker.getProperty("value"), + }); + + expect(await getInputValue(page)).toBe(expectedInputValue); + }); + }); }); }); diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.stories.ts b/packages/calcite-components/src/components/input-time-picker/input-time-picker.stories.ts index a7abf9b6c26..fac34b292f8 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.stories.ts +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.stories.ts @@ -3,6 +3,7 @@ import { boolean, createBreakpointStories, modesDarkDefault } from "../../../.st import { html } from "../../../support/formatting"; import { defaultMenuPlacement, menuPlacements } from "../../utils/floating-ui"; import { ATTRIBUTES } from "../../../.storybook/resources"; +import { hourFormats } from "../../utils/time"; import { InputTimePicker } from "./input-time-picker"; const { scale, status } = ATTRIBUTES; @@ -10,7 +11,16 @@ const { scale, status } = ATTRIBUTES; interface InputTimePickerStoryArgs extends Pick< InputTimePicker, - "disabled" | "name" | "placement" | "scale" | "status" | "step" | "validationMessage" | "validationIcon" | "value" + | "disabled" + | "hourFormat" + | "name" + | "placement" + | "scale" + | "status" + | "step" + | "validationMessage" + | "validationIcon" + | "value" > { hidden: boolean; } @@ -20,6 +30,7 @@ export default { args: { disabled: false, hidden: false, + hourFormat: undefined, name: "simple", placement: defaultMenuPlacement, scale: scale.defaultValue, @@ -30,6 +41,10 @@ export default { value: "10:37", }, argTypes: { + hourFormat: { + options: hourFormats, + control: { type: "select" }, + }, placement: { options: menuPlacements, control: { type: "select" }, @@ -53,6 +68,7 @@ export const simple = (args: InputTimePickerStoryArgs): string => html` { setUpLoadableComponent(this); - await this.loadDateTimeLocaleData(); + await this.loadLocaleData(); + this.updateLocale(); } override willUpdate(changes: PropertyValues): void { @@ -382,23 +415,27 @@ export class InputTimePicker } } + if (changes.has("hourFormat")) { + this.updateLocale(); + } + if (changes.has("readOnly") && (this.hasUpdated || this.readOnly !== false)) { if (!this.readOnly) { this.open = false; } } + if (changes.has("messages")) { + this.langWatcher(); + } + if (changes.has("numberingSystem")) { - this.numberingSystemWatcher(this.numberingSystem); + this.setLocalizedInputValue({ numberingSystem: changes.get("numberingSystem") }); } if (changes.has("step") && (this.hasUpdated || this.step !== 60)) { this.stepWatcher(this.step, changes.get("step")); } - - if (changes.has("messages")) { - this.effectiveLocaleWatcher(this.messages._lang); - } } override updated(): void { @@ -408,15 +445,7 @@ export class InputTimePicker loaded(): void { setComponentLoaded(this); if (isValidTime(this.value)) { - this.setInputValue( - localizeTimeString({ - value: this.value, - locale: this.messages._lang, - numberingSystem: this.numberingSystem, - includeSeconds: this.shouldIncludeSeconds(), - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }), - ); + this.setLocalizedInputValue(); } } @@ -429,6 +458,11 @@ export class InputTimePicker // #region Private Methods + private async langWatcher(): Promise { + await this.loadLocaleData(); + this.updateLocale(); + } + private openHandler(): void { if (this.disabled || this.readOnly) { return; @@ -440,18 +474,6 @@ export class InputTimePicker } } - private numberingSystemWatcher(numberingSystem: NumberingSystem): void { - this.setInputValue( - localizeTimeString({ - value: this.value, - locale: this.messages._lang, - numberingSystem, - includeSeconds: this.shouldIncludeSeconds(), - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }), - ); - } - private stepWatcher(newStep: number, oldStep?: number): void { if ( (oldStep >= 60 && newStep > 0 && newStep < 60) || @@ -468,43 +490,14 @@ export class InputTimePicker this.userChangedValue = false; } - private async effectiveLocaleWatcher(locale: SupportedLocale): Promise { - await this.loadDateTimeLocaleData(); - - this.setInputValue( - localizeTimeString({ - value: this.value, - locale, - numberingSystem: this.numberingSystem, - includeSeconds: this.shouldIncludeSeconds(), - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }), - ); - } - private hostBlurHandler(): void { - const inputValue = this.calciteInputEl.value; - const delocalizedInputValue = this.delocalizeTimeString(inputValue); + const delocalizedInputValue = this.delocalizeTimeString(this.calciteInputEl.value); if (!delocalizedInputValue) { this.setValue(""); - return; - } - - if (delocalizedInputValue !== this.value) { + } else if (delocalizedInputValue !== this.value) { this.setValue(delocalizedInputValue); - } - - const localizedTimeString = localizeTimeString({ - value: this.value, - locale: this.messages._lang, - numberingSystem: this.numberingSystem, - includeSeconds: this.shouldIncludeSeconds(), - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }); - - if (localizedTimeString !== inputValue) { - this.setInputValue(localizedTimeString); + this.setLocalizedInputValue(); } this.deactivate(); @@ -551,15 +544,15 @@ export class InputTimePicker const value = target.value; const includeSeconds = this.shouldIncludeSeconds(); this.setValue(toISOTimeString(value, includeSeconds)); - this.setInputValue( - localizeTimeString({ - value, - locale: this.messages._lang, - numberingSystem: this.numberingSystem, - includeSeconds, - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }), - ); + this.setLocalizedInputValue({ isoTimeString: value }); + } + + private updateLocale(locale: SupportedLocale = this.messages._lang): void { + this.effectiveHourFormat = + this.hourFormat === "user" ? getLocaleHourFormat(this.messages._lang) : this.hourFormat; + this.localeDefaultLTFormat = this.localeConfig.formats.LT; + this.localeDefaultLTSFormat = this.localeConfig.formats.LTS; + this.setLocalizedInputValue({ locale }); } private popoverBeforeOpenHandler(event: CustomEvent): void { @@ -589,7 +582,7 @@ export class InputTimePicker private delocalizeTimeString(value: string): string { // we need to set the corresponding locale before parsing, otherwise it defaults to English (possible dayjs bug) - dayjs.locale(this.messages._lang.toLowerCase()); + dayjs.locale(this.getSupportedDayjsLocale(this.messages._lang.toLowerCase())); const nonFractionalSecondParts = this.delocalizeTimeStringToParts(value); @@ -637,28 +630,38 @@ export class InputTimePicker } private delocalizeTimeStringToParts( - localizedTimeString: string, + value: string, fractionalSecondFormatToken?: "S" | "SS" | "SSS", ): DayjsTimeParts { - const ltsFormatString = this.localeConfig?.formats?.LTS; - const fractionalSecondTokenMatch = ltsFormatString.match(/ss\.*(S+)/g); - - if (fractionalSecondFormatToken && this.shouldIncludeFractionalSeconds()) { - const secondFormatToken = `ss.${fractionalSecondFormatToken}`; - this.localeConfig.formats.LTS = fractionalSecondTokenMatch - ? ltsFormatString.replace(fractionalSecondTokenMatch[0], secondFormatToken) - : ltsFormatString.replace("ss", secondFormatToken); - } else if (fractionalSecondTokenMatch) { - this.localeConfig.formats.LTS = ltsFormatString.replace(fractionalSecondTokenMatch[0], "ss"); + const effectiveLocale = this.messages._lang; + let localizedTimeString = value; + const effectiveHourFormat = isLocaleHourFormatOpposite( + this.effectiveHourFormat, + effectiveLocale, + ) + ? getLocaleOppositeHourFormat(effectiveLocale) + : getLocaleHourFormat(effectiveLocale); + + if (localizedTwentyFourHourMeridiems.has(effectiveLocale) && effectiveHourFormat === "12") { + const localizedAM = localizedTwentyFourHourMeridiems.get(effectiveLocale).am; + const localizedPM = localizedTwentyFourHourMeridiems.get(effectiveLocale).pm; + const meridiemFormatToken = getMeridiemFormatToken(effectiveLocale); + const caseAdjustedAMString = + meridiemFormatToken === meridiemFormatToken.toUpperCase() ? "AM" : "am"; + const caseAdjustedPMString = + meridiemFormatToken === meridiemFormatToken.toUpperCase() ? "PM" : "pm"; + + localizedTimeString = localizedTimeString.includes(localizedPM) + ? localizedTimeString.replaceAll(localizedPM, caseAdjustedPMString) + : localizedTimeString.replaceAll(localizedAM, caseAdjustedAMString); } - dayjs.updateLocale( - this.getSupportedDayjsLocale(getSupportedLocale(this.messages._lang)), - this.localeConfig as Record, - ); + this.setLocaleTimeFormat({ + fractionalSecondFormatToken, + hourFormat: effectiveHourFormat, + }); const dayjsParseResult = dayjs(localizedTimeString, ["LTS", "LT"]); - if (dayjsParseResult.isValid()) { return { hour: dayjsParseResult.get("hour"), @@ -714,18 +717,9 @@ export class InputTimePicker if (isValidTime(newValue)) { this.setValue(newValue); - - const localizedTimeString = localizeTimeString({ - value: this.value, - locale: this.messages._lang, - numberingSystem: this.numberingSystem, - includeSeconds: this.shouldIncludeSeconds(), - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }); - - if (newValue && this.calciteInputEl.value !== localizedTimeString) { - this.setInputValue(localizedTimeString); - } + this.setLocalizedInputValue(); + } else { + this.setValue(""); } } else if (key === "ArrowDown") { this.open = true; @@ -747,7 +741,7 @@ export class InputTimePicker return dayjsLocale; } - private async loadDateTimeLocaleData(): Promise { + private async loadLocaleData(): Promise { let supportedLocale = getSupportedLocale(this.messages._lang).toLowerCase(); supportedLocale = this.getSupportedDayjsLocale(supportedLocale); @@ -762,18 +756,27 @@ export class InputTimePicker } private getExtendedLocaleConfig( - locale: string, + locale: SupportedLocale, ): Parameters<(typeof dayjs)["updateLocale"]>[1] | undefined { + /* + * Meridiem and format tokens below are based on https://github.com/unicode-org/cldr-json/ + * + * To reference a specific locale, check: + * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-dates-modern/main//ca-generic.json + * + * Example (es-MX): + * https://github.com/unicode-org/cldr-json/blob/d38478855dd8342749f0494332cc8acc2895d20d/cldr-json/cldr-dates-modern/main/es-MX/ca-generic.json#L227 + */ if (locale === "ar") { return { meridiem: (hour: number) => (hour > 12 ? "م" : "ص"), formats: { - LT: "HH:mm A", - LTS: "HH:mm:ss A", + LT: "h:mm a", + LTS: "h:mm:ss a", L: "DD/MM/YYYY", LL: "D MMMM YYYY", - LLL: "D MMMM YYYY HH:mm A", - LLLL: "dddd D MMMM YYYY HH:mm A", + LLL: "D MMMM YYYY h:mm a", + LLLL: "dddd D MMMM YYYY h:mm a", }, }; } @@ -796,6 +799,19 @@ export class InputTimePicker }; } + if (locale === "es-mx") { + return { + formats: { + LT: "h:mm a", + LTS: "h:mm:ss a", + L: "DD/MM/YYYY", + LL: "D [de] MMMM [de] YYYY", + LLL: "D [de] MMMM [de] YYYY H:mm", + LLLL: "dddd, D [de] MMMM [de] YYYY H:mm", + }, + }; + } + if (locale === "hi") { return { formats: { @@ -810,17 +826,41 @@ export class InputTimePicker }; } + if (locale === "ja") { + return { + meridiem: (hour) => (hour > 12 ? "午後" : "午前"), + }; + } + if (locale === "ko") { return { meridiem: (hour: number) => (hour > 12 ? "오후" : "오전"), }; } + if (locale === "no") { + return { + meridiem: (hour: number) => (hour > 12 ? "p.m." : "a.m."), + }; + } + + if (locale === "ru") { + return { + meridiem: (hour: number) => (hour > 12 ? "PM" : "AM"), + }; + } + + if (locale === "zh-cn") { + return { + meridiem: (hour: number) => (hour > 12 ? "下午" : "上午"), + }; + } + if (locale === "zh-tw") { return { formats: { - LT: "AHH:mm", - LTS: "AHH:mm:ss", + LT: "Ah:mm", + LTS: "Ah:mm:ss", }, }; } @@ -828,14 +868,33 @@ export class InputTimePicker if (locale === "zh-hk") { return { formats: { - LT: "AHH:mm", - LTS: "AHH:mm:ss", + LT: "Ah:mm", + LTS: "Ah:mm:ss", }, meridiem: (hour: number) => (hour > 12 ? "下午" : "上午"), }; } } + private getLocalizedTimeString(params?: GetLocalizedTimeStringParameters): string { + const hour12 = + params?.hourFormat === "12" || + (this.effectiveHourFormat && this.effectiveHourFormat === "12"); + const locale = params?.locale ?? this.messages._lang; + const numberingSystem = params?.numberingSystem ?? this.numberingSystem; + const value = params?.isoTimeString ?? this.value; + return ( + localizeTimeString({ + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, + hour12, + includeSeconds: this.shouldIncludeSeconds(), + locale, + numberingSystem, + value, + }) ?? "" + ); + } + onLabelClick(): void { this.setFocus(); } @@ -867,6 +926,69 @@ export class InputTimePicker this.calciteTimePickerEl = el; } + private setLocaleTimeFormat({ + fractionalSecondFormatToken, + hourFormat, + }: { + fractionalSecondFormatToken?: "S" | "SS" | "SSS"; + hourFormat: EffectiveHourFormat; + }): void { + const effectiveLocale = this.messages._lang; + const localeDefaultHourFormat = getLocaleHourFormat(effectiveLocale); + const hourRegEx = /h+|H+/g; + const meridiemRegEx = /\s+|a+|A+|\s+/g; + + let ltFormatString = this.localeConfig.formats.LT; + let ltsFormatString = this.localeConfig.formats.LTS; + + if (hourFormat === "12" && localeDefaultHourFormat === "24") { + const meridiemFormatToken = getMeridiemFormatToken(effectiveLocale); + const meridiemOrder = getMeridiemOrder(effectiveLocale); + ltFormatString = ltFormatString.replaceAll(hourRegEx, "h"); + ltFormatString = ltFormatString.replaceAll(meridiemRegEx, ""); + ltFormatString = + meridiemOrder === 0 + ? `${meridiemFormatToken}${ltFormatString}` + : `${ltFormatString}${meridiemFormatToken}`; + ltsFormatString = ltsFormatString.replaceAll(hourRegEx, "h"); + ltsFormatString = ltsFormatString.replaceAll(meridiemRegEx, ""); + ltsFormatString = + meridiemOrder === 0 + ? `${meridiemFormatToken}${ltsFormatString}` + : `${ltsFormatString}${meridiemFormatToken}`; + } else if (hourFormat === "24" && localeDefaultHourFormat === "12") { + ltFormatString = ltFormatString.replaceAll(hourRegEx, "H"); + ltFormatString = ltFormatString.replaceAll(meridiemRegEx, ""); + ltsFormatString = ltsFormatString.replaceAll(hourRegEx, "H"); + ltsFormatString = ltsFormatString.replaceAll(meridiemRegEx, ""); + } else { + ltFormatString = this.localeDefaultLTFormat; + ltsFormatString = this.localeDefaultLTSFormat; + } + + const fractionalSecondTokenMatch = ltsFormatString?.match(/ss\.*(S+)/g); + if (fractionalSecondFormatToken && this.shouldIncludeFractionalSeconds()) { + const secondFormatToken = `ss.${fractionalSecondFormatToken}`; + ltsFormatString = fractionalSecondTokenMatch + ? ltsFormatString.replace(fractionalSecondTokenMatch[0], secondFormatToken) + : ltsFormatString.replace("ss", secondFormatToken); + } else if (fractionalSecondTokenMatch) { + ltsFormatString = ltsFormatString.replace(fractionalSecondTokenMatch[0], "ss"); + } + + this.localeConfig.formats.LT = ltFormatString; + this.localeConfig.formats.LTS = ltsFormatString; + + dayjs.updateLocale( + this.getSupportedDayjsLocale(getSupportedLocale(effectiveLocale)), + this.localeConfig as Record, + ); + } + + private setLocalizedInputValue = (params?: GetLocalizedTimeStringParameters): void => { + this.setInputValue(this.getLocalizedTimeString(params)); + }; + private setInputValue(newInputValue: string): void { if (!this.calciteInputEl) { return; @@ -896,15 +1018,7 @@ export class InputTimePicker if (changeEvent.defaultPrevented) { this.userChangedValue = false; this.value = oldValue; - this.setInputValue( - localizeTimeString({ - value: oldValue, - locale: this.messages._lang, - numberingSystem: this.numberingSystem, - includeSeconds: this.shouldIncludeSeconds(), - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }), - ); + this.setLocalizedInputValue({ isoTimeString: oldValue }); } } @@ -917,17 +1031,7 @@ export class InputTimePicker private setValueDirectly(value: string): void { const includeSeconds = this.shouldIncludeSeconds(); this.value = toISOTimeString(value, includeSeconds); - this.setInputValue( - this.value - ? localizeTimeString({ - value: this.value, - includeSeconds, - locale: this.messages._lang, - numberingSystem: this.numberingSystem, - fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, - }) - : "", - ); + this.setLocalizedInputValue(); } private onInputWrapperClick() { @@ -984,6 +1088,7 @@ export class InputTimePicker triggerDisabled={true} > { expect(fractionalSecondEl.textContent).toEqual("000"); }); }); + + describe("l10n", () => { + supportedLocales.forEach((locale) => { + const localeHourFormat = getLocaleHourFormat(locale); + describe(`${locale} (${localeHourFormat}-hour)`, () => { + it(`uses the locale's preferred setting when hour-format="user"`, async () => { + const initialDelocalizedValue = "14:02:30.001"; + const page = await newE2EPage(); + await page.setContent(html` + + `); + + const meridiemEl = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + if (localeHourFormat === "12") { + expect(meridiemEl).toBeDefined(); + } else { + expect(meridiemEl).toBeNull(); + } + }); + + it("supports localized 12-hour format", async () => { + const initialDelocalizedValue = "14:02:30.001"; + const page = await newE2EPage(); + await page.setContent(html` + + `); + + const { + localizedHour: expectedLocalizedHour, + localizedHourSuffix: expectedLocalizedHourSuffix, + localizedMinute: expectedLocalizedMinute, + localizedMinuteSuffix: expectedLocalizedMinuteSuffix, + localizedSecond: expectedLocalizedSecond, + localizedSecondSuffix: expectedLocalizedSecondSuffix, + localizedDecimalSeparator: expectedLocalizedDecimalSeparator, + localizedFractionalSecond: expectedLocalizedFractionalSecond, + localizedMeridiem: expectedLocalizedMeridiem, + } = localizeTimeStringToParts({ + hour12: true, + value: initialDelocalizedValue, + locale, + }); + + const hourEl = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const hourSuffixEl = await page.find(`calcite-time-picker >>> .${CSS.hourSuffix}`); + const minuteEl = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const minuteSuffixEl = await page.find(`calcite-time-picker >>> .${CSS.minuteSuffix}`); + const secondEl = await page.find(`calcite-time-picker >>> .${CSS.second}`); + const decimalSeparatorEl = await page.find(`calcite-time-picker >>> .${CSS.decimalSeparator}`); + const fractionalSecondEl = await page.find(`calcite-time-picker >>> .${CSS.fractionalSecond}`); + const secondSuffixEl = await page.find(`calcite-time-picker >>> .${CSS.secondSuffix}`); + const meridiemEl = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + expect(hourEl).toEqualText(expectedLocalizedHour); + expect(hourSuffixEl).toEqualText(expectedLocalizedHourSuffix); + expect(minuteEl).toEqualText(expectedLocalizedMinute); + expect(minuteSuffixEl).toEqualText(expectedLocalizedMinuteSuffix); + expect(secondEl).toEqualText(expectedLocalizedSecond); + expect(decimalSeparatorEl).toEqualText(expectedLocalizedDecimalSeparator); + expect(fractionalSecondEl).toEqualText(expectedLocalizedFractionalSecond); + if (secondSuffixEl) { + // Bulgarian is the only locale Calcite supports that has a known suffix after the seconds. + // Esri i18n prefers this character be removed for short time formats, which is the only format currently that time-picker supports. + // We're leaving this conditional check here in case a new locale is added in the future that might need to test the second suffix. + expect(secondSuffixEl).toEqualText(expectedLocalizedSecondSuffix); + } + expect(meridiemEl).toEqualText(expectedLocalizedMeridiem); + }); + + it("supports localized 24-hour format", async () => { + const initialDelocalizedValue = "14:02:30.001"; + const page = await newE2EPage(); + await page.setContent(html` + + `); + + const { + localizedHour: expectedLocalizedHour, + localizedHourSuffix: expectedLocalizedHourSuffix, + localizedMinute: expectedLocalizedMinute, + localizedMinuteSuffix: expectedLocalizedMinuteSuffix, + localizedSecond: expectedLocalizedSecond, + localizedSecondSuffix: expectedLocalizedSecondSuffix, + localizedDecimalSeparator: expectedLocalizedDecimalSeparator, + localizedFractionalSecond: expectedLocalizedFractionalSecond, + } = localizeTimeStringToParts({ + hour12: false, + value: initialDelocalizedValue, + locale, + }); + + const hourEl = await page.find(`calcite-time-picker >>> .${CSS.hour}`); + const hourSuffixEl = await page.find(`calcite-time-picker >>> .${CSS.hourSuffix}`); + const minuteEl = await page.find(`calcite-time-picker >>> .${CSS.minute}`); + const minuteSuffixEl = await page.find(`calcite-time-picker >>> .${CSS.minuteSuffix}`); + const secondEl = await page.find(`calcite-time-picker >>> .${CSS.second}`); + const decimalSeparatorEl = await page.find(`calcite-time-picker >>> .${CSS.decimalSeparator}`); + const fractionalSecondEl = await page.find(`calcite-time-picker >>> .${CSS.fractionalSecond}`); + const secondSuffixEl = await page.find(`calcite-time-picker >>> .${CSS.secondSuffix}`); + const meridiemEl = await page.find(`calcite-time-picker >>> .${CSS.meridiem}`); + + expect(hourEl).toEqualText(expectedLocalizedHour); + expect(hourSuffixEl).toEqualText(expectedLocalizedHourSuffix); + expect(minuteEl).toEqualText(expectedLocalizedMinute); + expect(minuteSuffixEl).toEqualText(expectedLocalizedMinuteSuffix); + expect(secondEl).toEqualText(expectedLocalizedSecond); + expect(decimalSeparatorEl).toEqualText(expectedLocalizedDecimalSeparator); + expect(fractionalSecondEl).toEqualText(expectedLocalizedFractionalSecond); + if (secondSuffixEl) { + // Bulgarian is the only locale Calcite supports that has a known suffix after the seconds. + // Esri i18n prefers this character be removed for short time formats, which is the only format currently that time-picker supports. + // We're leaving this conditional check here in case a new locale is added in the future that might need to test the second suffix. + expect(secondSuffixEl).toEqualText(expectedLocalizedSecondSuffix); + } + expect(meridiemEl).toBeNull(); + }); + }); + }); + }); }); diff --git a/packages/calcite-components/src/components/time-picker/time-picker.tsx b/packages/calcite-components/src/components/time-picker/time-picker.tsx index 516c81226c5..910f40fec3d 100644 --- a/packages/calcite-components/src/components/time-picker/time-picker.tsx +++ b/packages/calcite-components/src/components/time-picker/time-picker.tsx @@ -6,13 +6,14 @@ import { isValidNumber } from "../../utils/number"; import { Scale } from "../interfaces"; import { NumberingSystem } from "../../utils/locale"; import { + EffectiveHourFormat, formatTimePart, - getLocaleHourCycle, + getLocaleHourFormat, getLocalizedDecimalSeparator, getLocalizedTimePartSuffix, getMeridiem, getMeridiemOrder, - HourCycle, + HourFormat, isValidTime, localizeTimePart, localizeTimeStringToParts, @@ -30,6 +31,7 @@ import { setUpLoadableComponent, } from "../../utils/loadable"; import { decimalPlaces, getDecimals } from "../../utils/math"; +import { getElementDir } from "../../utils/dom"; import { useT9n } from "../../controllers/useT9n"; import { CSS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -47,7 +49,6 @@ function capitalize(str: string): string { export class TimePicker extends LitElement implements LoadableComponent { // #region Static Members - static override shadowRootOptions = { mode: "open" as const, delegatesFocus: true }; static override styles = styles; @@ -76,12 +77,12 @@ export class TimePicker extends LitElement implements LoadableComponent { @state() activeEl: HTMLSpanElement; + @state() effectiveHourFormat: EffectiveHourFormat; + @state() fractionalSecond: string; @state() hour: string; - @state() hourCycle: HourCycle; - @state() localizedDecimalSeparator = "."; @state() localizedFractionalSecond: string; @@ -114,6 +115,17 @@ export class TimePicker extends LitElement implements LoadableComponent { // #region Public Properties + /** + * Specifies the component's hour format, where: + * + * `"user"` displays the user's locale format, + * `"12"` displays a 12-hour format, and + * `"24"` displays a 24-hour format. + * + * @default "user" + */ + @property({ reflect: true }) hourFormat: HourFormat = "user"; + /** Use this property to override individual strings used by the component. */ @property() messageOverrides?: typeof this.messages._overrides; @@ -188,7 +200,7 @@ export class TimePicker extends LitElement implements LoadableComponent { this.setValue(this.value); } - if (changes.has("messages")) { + if (changes.has("hourFormat") || changes.has("messages")) { this.updateLocale(); } } @@ -230,7 +242,7 @@ export class TimePicker extends LitElement implements LoadableComponent { if (this.step !== 60) { this.focusPart("second"); event.preventDefault(); - } else if (this.hourCycle === "12") { + } else if (this.effectiveHourFormat === "12") { this.focusPart("meridiem"); event.preventDefault(); } @@ -246,7 +258,7 @@ export class TimePicker extends LitElement implements LoadableComponent { case "ArrowRight": if (this.showFractionalSecond) { this.focusPart("fractionalSecond"); - } else if (this.hourCycle === "12") { + } else if (this.effectiveHourFormat === "12") { this.focusPart("meridiem"); event.preventDefault(); } @@ -260,7 +272,7 @@ export class TimePicker extends LitElement implements LoadableComponent { event.preventDefault(); break; case "ArrowRight": - if (this.hourCycle === "12") { + if (this.effectiveHourFormat === "12") { this.focusPart("meridiem"); event.preventDefault(); } @@ -296,7 +308,7 @@ export class TimePicker extends LitElement implements LoadableComponent { } private decrementHour(): void { - const newHour = !this.hour ? 0 : this.hour === "00" ? 23 : parseInt(this.hour) - 1; + const newHour = !this.hour ? 0 : parseInt(this.hour) === 0 ? 23 : parseInt(this.hour) - 1; this.setValuePart("hour", newHour); } @@ -395,7 +407,7 @@ export class TimePicker extends LitElement implements LoadableComponent { const keyAsNumber = parseInt(key); let newHour; if (isValidNumber(this.hour)) { - switch (this.hourCycle) { + switch (this.effectiveHourFormat) { case "12": newHour = this.hour === "01" && keyAsNumber >= 0 && keyAsNumber <= 2 @@ -686,6 +698,7 @@ export class TimePicker extends LitElement implements LoadableComponent { if (isValidTime(value)) { const { hour, minute, second, fractionalSecond } = parseTimeString(value); const { + effectiveHourFormat, messages: { _lang: locale }, numberingSystem, } = this; @@ -699,7 +712,12 @@ export class TimePicker extends LitElement implements LoadableComponent { localizedFractionalSecond, localizedSecondSuffix, localizedMeridiem, - } = localizeTimeStringToParts({ value, locale, numberingSystem }); + } = localizeTimeStringToParts({ + value, + locale, + numberingSystem, + hour12: effectiveHourFormat === "12", + }); this.hour = hour; this.minute = minute; this.second = second; @@ -755,6 +773,7 @@ export class TimePicker extends LitElement implements LoadableComponent { value: number | string | Meridiem, ): void { const { + effectiveHourFormat, messages: { _lang: locale }, numberingSystem, } = this; @@ -822,8 +841,12 @@ export class TimePicker extends LitElement implements LoadableComponent { } this.value = newValue; this.localizedMeridiem = this.value - ? localizeTimeStringToParts({ value: this.value, locale, numberingSystem }) - ?.localizedMeridiem || null + ? localizeTimeStringToParts({ + hour12: effectiveHourFormat === "12", + locale, + numberingSystem, + value: this.value, + })?.localizedMeridiem || null : localizeTimePart({ value: this.meridiem, part: "meridiem", locale, numberingSystem }); if (emit) { this.calciteInternalTimePickerChange.emit(); @@ -836,7 +859,8 @@ export class TimePicker extends LitElement implements LoadableComponent { } private updateLocale() { - this.hourCycle = getLocaleHourCycle(this.messages._lang, this.numberingSystem); + this.effectiveHourFormat = + this.hourFormat === "user" ? getLocaleHourFormat(this.messages._lang) : this.hourFormat; this.localizedDecimalSeparator = getLocalizedDecimalSeparator( this.messages._lang, this.numberingSystem, @@ -847,15 +871,14 @@ export class TimePicker extends LitElement implements LoadableComponent { // #endregion - // #region Rendering - override render(): JsxNode { const hourIsNumber = isValidNumber(this.hour); const iconScale = getIconScale(this.scale); const minuteIsNumber = isValidNumber(this.minute); const secondIsNumber = isValidNumber(this.second); const fractionalSecondIsNumber = isValidNumber(this.fractionalSecond); - const showMeridiem = this.hourCycle === "12"; + const showSecondSuffix = this.messages._lang !== "bg" && this.localizedSecondSuffix; + const showMeridiem = this.effectiveHourFormat === "12"; return (
- {this.localizedHourSuffix} + + {this.localizedHourSuffix} +
- {this.showSecond && {this.localizedMinuteSuffix}} + {this.showSecond && ( + + {this.localizedMinuteSuffix} + + )} {this.showSecond && (
)} {this.showFractionalSecond && ( - {this.localizedDecimalSeparator} + + {this.localizedDecimalSeparator} + )} {this.showFractionalSecond && (
@@ -1053,14 +1084,16 @@ export class TimePicker extends LitElement implements LoadableComponent {
)} - {this.localizedSecondSuffix && ( - {this.localizedSecondSuffix} + {showSecondSuffix && ( + + {this.localizedSecondSuffix} + )} {showMeridiem && (
diff --git a/packages/calcite-components/src/demos/_assets/locales.ts b/packages/calcite-components/src/demos/_assets/locales.ts index 934734189f9..fe95833fc17 100644 --- a/packages/calcite-components/src/demos/_assets/locales.ts +++ b/packages/calcite-components/src/demos/_assets/locales.ts @@ -1,10 +1,19 @@ -import { NumberingSystem } from "../../utils/locale"; +import { NumberingSystem, SupportedLocale } from "../../utils/locale"; +import { HourFormat } from "../../utils/time"; interface Locale { name: string; - locale: string; + locale: SupportedLocale; dir?: "ltr" | "rtl"; numberingSystem?: NumberingSystem; + /* + * Hour formats below are based on: + * @see https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/supplemental/timeData.json + * + * To reference a specific locale, search for the lang code in the timeData.json file and see the "_preferred" value. + * The value "h" generally refers to a 12-hour clock format, whereas "H" refers to a 24-hour style. + */ + hourFormat: HourFormat; } export const locales: Locale[] = [ @@ -12,225 +21,276 @@ export const locales: Locale[] = [ name: "Arabic", locale: "ar", dir: "rtl", + hourFormat: "12", }, { - name: "Arabic (Arab Numerals)", + name: "Arabic", locale: "ar", dir: "rtl", numberingSystem: "arab", + hourFormat: "12", }, { - name: "Arabic (Arab Ext Numerals)", + name: "Arabic", locale: "ar", dir: "rtl", numberingSystem: "arabext", + hourFormat: "12", }, { name: "Bulgarian", locale: "bg", + hourFormat: "24", }, { name: "Bosnian", locale: "bs", + hourFormat: "24", }, { name: "Catalan", locale: "ca", + hourFormat: "24", }, { name: "Czech", locale: "cs", + hourFormat: "24", }, { name: "Danish", locale: "da", + hourFormat: "24", }, { name: "German", locale: "de", + hourFormat: "24", }, { - name: "German (Austria)", + name: "German - Austria", locale: "de-AT", + hourFormat: "24", }, { - name: "German (Switzerland)", + name: "German - Switzerland", locale: "de-CH", + hourFormat: "24", }, { name: "Greek", locale: "el", + hourFormat: "12", }, { name: "English", locale: "en", + hourFormat: "12", }, { - name: "English (Australia)", + name: "English - Australia", locale: "en-AU", + hourFormat: "12", }, { - name: "English (Canada)", + name: "English - Canada", locale: "en-CA", + hourFormat: "12", }, { - name: "English (Great Britain)", + name: "English - Great Britain", locale: "en-GB", + hourFormat: "24", }, { - name: "English (United States)", + name: "English - United States", locale: "en-US", + hourFormat: "12", }, { name: "Spanish", locale: "es", + hourFormat: "24", }, { - name: "Spanish (Mexico)", + name: "Spanish - Mexico", locale: "es-MX", + hourFormat: "12", }, { name: "Estonian", locale: "et", + hourFormat: "24", }, { name: "Finnish", locale: "fi", + hourFormat: "24", }, { name: "French", locale: "fr", + hourFormat: "24", }, { - name: "French (Switzerland)", + name: "French - Switzerland", locale: "fr-CH", + hourFormat: "24", }, { name: "Hebrew", locale: "he", + dir: "rtl", + hourFormat: "24", }, { name: "Hindi", locale: "hi", + hourFormat: "12", }, { name: "Croatian", locale: "hr", + hourFormat: "24", }, { name: "Hungarian", locale: "hu", + hourFormat: "24", }, { - name: "Indonesian (ISO 3166)", + name: "Indonesian", locale: "id", + hourFormat: "24", }, { name: "Italian", locale: "it", + hourFormat: "24", }, { - name: "Italian (Switzerland)", + name: "Italian - Switzerland", locale: "it-CH", + hourFormat: "24", }, { name: "Japanese", locale: "ja", + hourFormat: "24", }, { name: "Korean", locale: "ko", + hourFormat: "12", }, { name: "Lithuanian", locale: "lt", + hourFormat: "24", }, { name: "Latvian", locale: "lv", + hourFormat: "24", }, { name: "Macedonian", locale: "mk", + hourFormat: "24", }, { name: "Norwegian", locale: "no", + hourFormat: "24", }, { name: "Dutch", locale: "nl", + hourFormat: "24", }, { name: "Polish", locale: "pl", + hourFormat: "24", }, { name: "Portuguese", locale: "pt", + hourFormat: "24", }, { - name: "Portuguese (Brazil)", + name: "Portuguese - Brazil", locale: "pt-BR", + hourFormat: "24", }, { - name: "Portuguese (Portugal)", + name: "Portuguese", locale: "pt-PT", + hourFormat: "24", }, { name: "Romanian", locale: "ro", + hourFormat: "24", }, { name: "Russian", locale: "ru", + hourFormat: "24", }, { name: "Slovak", locale: "sk", + hourFormat: "24", }, { name: "Slovenian", locale: "sl", + hourFormat: "24", }, { name: "Serbian", locale: "sr", + hourFormat: "24", }, { name: "Swedish", locale: "sv", + hourFormat: "24", }, { name: "Thai", locale: "th", - }, - { - name: "Thai (Thai digits)", - locale: "th", + hourFormat: "24", }, { name: "Turkish", locale: "tr", + hourFormat: "24", }, { name: "Ukrainian", locale: "uk", + hourFormat: "24", }, { name: "Vietnamese", locale: "vi", + hourFormat: "24", }, { - name: "Chinese (China)", + name: "Chinese", locale: "zh-CN", + hourFormat: "24", }, { - name: "Chinese (Hong Kong)", + name: "Chinese - Hong Kong", locale: "zh-HK", + hourFormat: "12", }, { - name: "Chinese (Taiwan)", + name: "Chinese - Taiwan", locale: "zh-TW", + hourFormat: "12", }, ]; diff --git a/packages/calcite-components/src/demos/input-time-picker.html b/packages/calcite-components/src/demos/input-time-picker.html index 8c2867021b2..569528d500b 100644 --- a/packages/calcite-components/src/demos/input-time-picker.html +++ b/packages/calcite-components/src/demos/input-time-picker.html @@ -20,10 +20,10 @@ } .grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 1em; align-items: flex-start; - justify-items: center; + justify-items: start; padding: 1em; } .column { @@ -37,97 +37,126 @@
-

12-Hour Locales

-

24-Hour Locales

+

12-Hour Locales

+
+
+ - + const defaultHourFormatMilliSeconds = createInputTimePicker({ + ...options, + ...{ step: 0.001 }, + }); + const oppositeHourFormat = + hourFormat === "12" + ? createInputTimePicker({ + ...options, + ...{ hourFormat: "24" }, + }) + : createInputTimePicker({ + ...options, + ...{ hourFormat: "12" }, + }); + const oppositeHourFormatSeconds = + hourFormat === "12" + ? createInputTimePicker({ + ...options, + ...{ hourFormat: "24", step: 1 }, + }) + : createInputTimePicker({ + ...options, + ...{ hourFormat: "12", step: 1 }, + }); + const oppositeHourFormatMilliSeconds = + hourFormat === "12" + ? createInputTimePicker({ + ...options, + ...{ hourFormat: "24", step: 0.001 }, + }) + : createInputTimePicker({ + ...options, + ...{ hourFormat: "12", step: 0.001 }, + }); + if (hourFormat === "12") { + h12.append( + defaultHourFormat, + defaultHourFormatSeconds, + defaultHourFormatMilliSeconds, + oppositeHourFormat, + oppositeHourFormatSeconds, + oppositeHourFormatMilliSeconds, + ); + } else { + h23.append( + defaultHourFormat, + defaultHourFormatSeconds, + defaultHourFormatMilliSeconds, + oppositeHourFormat, + oppositeHourFormatSeconds, + oppositeHourFormatMilliSeconds, + ); + } + }); + })(); + + diff --git a/packages/calcite-components/src/utils/locale.ts b/packages/calcite-components/src/utils/locale.ts index a8432676ddd..798d236a42f 100644 --- a/packages/calcite-components/src/utils/locale.ts +++ b/packages/calcite-components/src/utils/locale.ts @@ -99,6 +99,40 @@ export const locales = [ "zh-TW", ]; +/** + * To reference the CLDR meridiems for each supported locale navigate to: + * https://github.com/unicode-org/cldr-json/tree/main/cldr-json/cldr-dates-full/main, + * click {locale}/ca-generic.json and drill down to main.{locale}.dates.calendars.generic.dayPeriods.format.abbreviated. + */ +export const localizedTwentyFourHourMeridiems = new Map( + Object.entries({ + bg: { am: "пр.об.", pm: "сл.об." }, + bs: { am: "prijepodne", pm: "popodne" }, + ca: { am: "a. m.", pm: "p. m." }, + cs: { am: "dop.", pm: "odp." }, + es: { am: "a. m.", pm: "p. m." }, + "es-mx": { am: "a.m.", pm: "p.m." }, + "es-MX": { am: "a.m.", pm: "p.m." }, + fi: { am: "ap.", pm: "ip." }, + he: { am: "לפנה״צ", pm: "אחה״צ" }, + hu: { am: "de. ", pm: "du." }, + lt: { am: "priešpiet", pm: "popiet" }, + lv: { am: "priekšpusdienā", pm: "pēcpusdienā" }, + mk: { am: "претпл.", pm: "попл." }, + no: { am: "a.m.", pm: "p.m." }, + nl: { am: "a.m.", pm: "p.m." }, + "pt-pt": { am: "da manhã", pm: "da tarde" }, + "pt-PT": { am: "da manhã", pm: "da tarde" }, + ro: { am: "a.m.", pm: "p.m." }, + sl: { am: "dop.", pm: "pop." }, + sv: { am: "fm", pm: "em" }, + th: { am: "ก่อนเที่ยง", pm: "หลังเที่ยง" }, + tr: { am: "ÖÖ", pm: "ÖS" }, + uk: { am: "дп", pm: "пп" }, + vi: { am: "SA", pm: "CH" }, + }), +); + export const numberingSystems = ["arab", "arabext", "latn"] as const; export const supportedLocales = [...new Set([...t9nLocales, ...locales])] as const; @@ -113,7 +147,7 @@ const isNumberingSystemSupported = (numberingSystem: string): numberingSystem is const browserNumberingSystem = new Intl.NumberFormat().resolvedOptions().numberingSystem; // for consistent browser behavior, we normalize numberingSystem to prevent the browser-inferred value -// see https://github.com/Esri/calcite-design-system/issues/3079#issuecomment-1168964195 for more info +// @see https://github.com/Esri/calcite-design-system/issues/3079#issuecomment-1168964195 export const defaultNumberingSystem = browserNumberingSystem === "arab" || !isNumberingSystemSupported(browserNumberingSystem) ? "latn" @@ -179,7 +213,7 @@ export function getSupportedLocale(locale: string, context: "cldr" | "t9n" = "cl * * Intl date formatting has some quirks with certain locales. This handles those quirks by mapping a locale to another for date formatting. * - * See https://github.com/Esri/calcite-design-system/issues/9387 + * @see https://github.com/Esri/calcite-design-system/issues/9387 * * @param locale – the BCP 47 locale code * @returns {string} a BCP 47 locale code @@ -291,7 +325,9 @@ export class NumberStringFormat { this._actualGroup = parts.find((d) => d.type === "group").value; // change whitespace group separators to the unicode non-breaking space (nbsp) this._group = this._actualGroup.trim().length === 0 || this._actualGroup == " " ? "\u00A0" : this._actualGroup; - this._decimal = parts.find((d) => d.type === "decimal").value; + // @see https://issues.chromium.org/issues/40656070 + this._decimal = + options.locale === "bs" || options.locale === "mk" ? "," : parts.find((d) => d.type === "decimal").value; this._minusSign = parts.find((d) => d.type === "minusSign").value; this._getDigitIndex = (d: string) => index.get(d); } diff --git a/packages/calcite-components/src/utils/time.spec.ts b/packages/calcite-components/src/utils/time.spec.ts index eea750c0bac..6471b140a50 100644 --- a/packages/calcite-components/src/utils/time.spec.ts +++ b/packages/calcite-components/src/utils/time.spec.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from "vitest"; import { formatTimePart, + getLocaleHourFormat, + getLocaleOppositeHourFormat, + getLocalizedMeridiem, getMeridiemOrder, + isLocaleHourFormatOpposite, isValidTime, localizeTimeStringToParts, parseTimeString, toISOTimeString, } from "./time"; +import { supportedLocales } from "./locale"; describe("formatTimePart", () => { it("returns decimals less than 1 with leading and trailing zeros to match the provided length", () => { @@ -34,24 +39,234 @@ describe("formatTimePart", () => { }); }); -describe("getMeridiemOrder", () => { - it("returns 0 for arabic lang", () => { - expect(getMeridiemOrder("ar")).toEqual(0); +describe("getLocalizedMeridiem", () => { + it("ar", () => { + expect(getLocalizedMeridiem("ar", "AM")).toEqual("ص"); + expect(getLocalizedMeridiem("ar", "PM")).toEqual("م"); + }); + it("bg", () => { + expect(getLocalizedMeridiem("bg", "AM")).toEqual("пр.об."); + expect(getLocalizedMeridiem("bg", "PM")).toEqual("сл.об."); + }); + it("bs", () => { + expect(getLocalizedMeridiem("bs", "AM")).toEqual("prijepodne"); + expect(getLocalizedMeridiem("bs", "PM")).toEqual("popodne"); + }); + it("ca", () => { + expect(getLocalizedMeridiem("ca", "AM")).toEqual("a. m."); + expect(getLocalizedMeridiem("ca", "PM")).toEqual("p. m."); + }); + it("cs", () => { + expect(getLocalizedMeridiem("cs", "AM")).toEqual("dop."); + expect(getLocalizedMeridiem("cs", "PM")).toEqual("odp."); + }); + it("da", () => { + expect(getLocalizedMeridiem("da", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("da", "PM")).toEqual("PM"); + }); + it("de", () => { + expect(getLocalizedMeridiem("de", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("de", "PM")).toEqual("PM"); + }); + it("de-AT", () => { + expect(getLocalizedMeridiem("de-AT", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("de-AT", "PM")).toEqual("PM"); + }); + it("de-CH", () => { + expect(getLocalizedMeridiem("de-CH", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("de-CH", "PM")).toEqual("PM"); + }); + it("en-GB", () => { + expect(getLocalizedMeridiem("en-GB", "AM")).toEqual("am"); + expect(getLocalizedMeridiem("en-GB", "PM")).toEqual("pm"); + }); + it("el", () => { + expect(getLocalizedMeridiem("el", "AM")).toEqual("π.μ."); + expect(getLocalizedMeridiem("el", "PM")).toEqual("μ.μ."); + }); + it("en-US", () => { + expect(getLocalizedMeridiem("en", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("en", "PM")).toEqual("PM"); + }); + it("en-AU", () => { + expect(getLocalizedMeridiem("en-AU", "AM")).toEqual("am"); + expect(getLocalizedMeridiem("en-AU", "PM")).toEqual("pm"); + }); + it("en-CA", () => { + expect(getLocalizedMeridiem("en-CA", "AM")).toEqual("a.m."); + expect(getLocalizedMeridiem("en-CA", "PM")).toEqual("p.m."); + }); + it("es", () => { + expect(getLocalizedMeridiem("es", "AM")).toEqual("a. m."); + expect(getLocalizedMeridiem("es", "PM")).toEqual("p. m."); + }); + it("es-MX", () => { + expect(getLocalizedMeridiem("es-MX", "AM")).toEqual("a.m."); + expect(getLocalizedMeridiem("es-MX", "PM")).toEqual("p.m."); + }); + it("et", () => { + expect(getLocalizedMeridiem("et", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("et", "PM")).toEqual("PM"); + }); + it("fi", () => { + expect(getLocalizedMeridiem("fi", "AM")).toEqual("ap."); + expect(getLocalizedMeridiem("fi", "PM")).toEqual("ip."); + }); + it("fr", () => { + expect(getLocalizedMeridiem("fr", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("fr", "PM")).toEqual("PM"); + }); + it("fr-CH", () => { + expect(getLocalizedMeridiem("fr-CH", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("fr-CH", "PM")).toEqual("PM"); + }); + it("he", () => { + expect(getLocalizedMeridiem("he", "AM")).toEqual("לפנה״צ"); + expect(getLocalizedMeridiem("he", "PM")).toEqual("אחה״צ"); + }); + it("hi", () => { + expect(getLocalizedMeridiem("hi", "AM")).toEqual("am"); + expect(getLocalizedMeridiem("hi", "PM")).toEqual("pm"); + }); + it("hu", () => { + expect(getLocalizedMeridiem("hu", "AM")).toEqual("de."); + expect(getLocalizedMeridiem("hu", "PM")).toEqual("du."); + }); + it("hr", () => { + expect(getLocalizedMeridiem("hr", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("hr", "PM")).toEqual("PM"); + }); + it("id", () => { + expect(getLocalizedMeridiem("id", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("id", "PM")).toEqual("PM"); + }); + it("italian", () => { + expect(getLocalizedMeridiem("it", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("it", "PM")).toEqual("PM"); + }); + it("it-CH", () => { + expect(getLocalizedMeridiem("it-CH", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("it-CH", "PM")).toEqual("PM"); + }); + it("ja", () => { + expect(getLocalizedMeridiem("ja", "AM")).toEqual("午前"); + expect(getLocalizedMeridiem("ja", "PM")).toEqual("午後"); + }); + it("ko", () => { + expect(getLocalizedMeridiem("ko", "AM")).toEqual("오전"); + expect(getLocalizedMeridiem("ko", "PM")).toEqual("오후"); + }); + it("lt", () => { + expect(getLocalizedMeridiem("lt", "AM")).toEqual("priešpiet"); + expect(getLocalizedMeridiem("lt", "PM")).toEqual("popiet"); }); - it("returns 0 for chinese (hong kong) lang", () => { - expect(getMeridiemOrder("zh-HK")).toEqual(0); + it("lv", () => { + expect(getLocalizedMeridiem("lv", "AM")).toEqual("priekšpusdienā"); + expect(getLocalizedMeridiem("lv", "PM")).toEqual("pēcpusdienā"); }); - it("returns 0 for hebrew lang", () => { - expect(getMeridiemOrder("he")).toEqual(0); + it("mk", () => { + expect(getLocalizedMeridiem("mk", "AM")).toEqual("претпл."); + expect(getLocalizedMeridiem("mk", "PM")).toEqual("попл."); }); - it("returns 0 for korean lang", () => { - expect(getMeridiemOrder("ko")).toEqual(0); + it("no", () => { + expect(getLocalizedMeridiem("no", "AM")).toEqual("a.m."); + expect(getLocalizedMeridiem("no", "PM")).toEqual("p.m."); }); - it("returns non-zero for ltr langs", () => { - expect(getMeridiemOrder("el")).not.toEqual(0); - expect(getMeridiemOrder("en")).not.toEqual(0); - expect(getMeridiemOrder("es")).not.toEqual(0); - expect(getMeridiemOrder("hi")).not.toEqual(0); + it("nl", () => { + expect(getLocalizedMeridiem("nl", "AM")).toEqual("a.m."); + expect(getLocalizedMeridiem("nl", "PM")).toEqual("p.m."); + }); + it("pl", () => { + expect(getLocalizedMeridiem("pl", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("pl", "PM")).toEqual("PM"); + }); + it("pt-BR", () => { + expect(getLocalizedMeridiem("pt-BR", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("pt-BR", "PM")).toEqual("PM"); + }); + it("pt-PT", () => { + expect(getLocalizedMeridiem("pt-PT", "AM")).toEqual("da manhã"); + expect(getLocalizedMeridiem("pt-PT", "PM")).toEqual("da tarde"); + }); + it("ro", () => { + expect(getLocalizedMeridiem("ro", "AM")).toEqual("a.m."); + expect(getLocalizedMeridiem("ro", "PM")).toEqual("p.m."); + }); + it("ru", () => { + expect(getLocalizedMeridiem("ru", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("ru", "PM")).toEqual("PM"); + }); + it("sl", () => { + expect(getLocalizedMeridiem("sl", "AM")).toEqual("dop."); + expect(getLocalizedMeridiem("sl", "PM")).toEqual("pop."); + }); + it("sr", () => { + expect(getLocalizedMeridiem("sr", "AM")).toEqual("AM"); + expect(getLocalizedMeridiem("sr", "PM")).toEqual("PM"); + }); + it("sv", () => { + expect(getLocalizedMeridiem("sv", "AM")).toEqual("fm"); + expect(getLocalizedMeridiem("sv", "PM")).toEqual("em"); + }); + it("th", () => { + expect(getLocalizedMeridiem("th", "AM")).toEqual("ก่อนเที่ยง"); + expect(getLocalizedMeridiem("th", "PM")).toEqual("หลังเที่ยง"); + }); + it("tr", () => { + expect(getLocalizedMeridiem("tr", "AM")).toEqual("ÖÖ"); + expect(getLocalizedMeridiem("tr", "PM")).toEqual("ÖS"); + }); + it("uk", () => { + expect(getLocalizedMeridiem("uk", "AM")).toEqual("дп"); + expect(getLocalizedMeridiem("uk", "PM")).toEqual("пп"); + }); + it("vi", () => { + expect(getLocalizedMeridiem("vi", "AM")).toEqual("SA"); + expect(getLocalizedMeridiem("vi", "PM")).toEqual("CH"); + }); + it("zh-CN", () => { + expect(getLocalizedMeridiem("zh-CN", "AM")).toEqual("上午"); + expect(getLocalizedMeridiem("zh-CN", "PM")).toEqual("下午"); + }); + it("zh-HK", () => { + expect(getLocalizedMeridiem("zh-HK", "AM")).toEqual("上午"); + expect(getLocalizedMeridiem("zh-HK", "PM")).toEqual("下午"); + }); + it("zh-TW", () => { + expect(getLocalizedMeridiem("zh-TW", "AM")).toEqual("上午"); + expect(getLocalizedMeridiem("zh-TW", "PM")).toEqual("下午"); + }); +}); + +describe("getMeridiemOrder", () => { + const nonZeroLangs = ["ar", "el", "en", "es", "he", "hi"]; + const zeroLangs = ["hu", "ja", "ko", "tr", "zh-CN", "zh-HK"]; + + nonZeroLangs.forEach((lang) => { + it(`returns non-zero for ${lang}`, () => { + expect(getMeridiemOrder(lang)).not.toEqual(0); + }); + }); + + zeroLangs.forEach((lang) => { + it(`returns zero for ${lang}`, () => { + expect(getMeridiemOrder(lang)).toEqual(0); + }); + }); +}); + +describe("hour-format utils", () => { + supportedLocales.forEach((locale) => { + const localeDefaultHourFormat = getLocaleHourFormat(locale); + it(`getLocaleOppositeHourFormat returns ${locale}'s opposite hour format`, () => { + const expected = localeDefaultHourFormat === "12" ? "24" : "12"; + expect(getLocaleOppositeHourFormat(locale)).toBe(expected); + }); + it(`isLocaleHourFormatOpposite returns true when ${locale}'s hour format is not set to its default and false otherwise`, () => { + const expected = localeDefaultHourFormat === "12"; + expect(isLocaleHourFormatOpposite("12", locale)).toBe(!expected); + expect(isLocaleHourFormatOpposite("24", locale)).toBe(expected); + }); }); }); diff --git a/packages/calcite-components/src/utils/time.ts b/packages/calcite-components/src/utils/time.ts index 7d0bb07f671..80c15c012f5 100644 --- a/packages/calcite-components/src/utils/time.ts +++ b/packages/calcite-components/src/utils/time.ts @@ -1,11 +1,22 @@ // @ts-strict-ignore -import { getDateTimeFormat, getSupportedNumberingSystem, NumberingSystem, numberStringFormatter } from "./locale"; +import { + getDateTimeFormat, + getSupportedNumberingSystem, + localizedTwentyFourHourMeridiems, + NumberingSystem, + numberStringFormatter, + SupportedLocale, +} from "./locale"; import { decimalPlaces } from "./math"; import { isValidNumber } from "./number"; export type FractionalSecondDigits = 1 | 2 | 3; -export type HourCycle = "12" | "24"; +export type EffectiveHourFormat = "12" | "24"; + +export type HourFormat = "user" | EffectiveHourFormat; + +export const hourFormats: EffectiveHourFormat[] = ["12", "24"]; export interface LocalizedTime { localizedHour: string; @@ -43,18 +54,30 @@ export type TimePart = export const maxTenthForMinuteAndSecond = 5; -function createLocaleDateTimeFormatter( - locale: string, - numberingSystem: NumberingSystem, +interface DateTimeFormatterOptions { + locale: SupportedLocale; + numberingSystem?: NumberingSystem; + includeSeconds?: boolean; + fractionalSecondDigits?: FractionalSecondDigits; + hour12?: boolean; +} + +function createLocaleDateTimeFormatter({ + locale, + numberingSystem, includeSeconds = true, - fractionalSecondDigits?: FractionalSecondDigits, -): Intl.DateTimeFormat { + fractionalSecondDigits, + hour12, +}: DateTimeFormatterOptions): Intl.DateTimeFormat { const options: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", timeZone: "UTC", numberingSystem: getSupportedNumberingSystem(numberingSystem), }; + if (typeof hour12 === "boolean") { + options.hour12 = hour12; + } if (includeSeconds) { options.second = "2-digit"; if (fractionalSecondDigits) { @@ -108,13 +131,55 @@ function fractionalSecondPartToMilliseconds(fractionalSecondPart: string): numbe return parseInt((parseFloat(`0.${fractionalSecondPart}`) / 0.001).toFixed(3)); } -export function getLocaleHourCycle(locale: string, numberingSystem: NumberingSystem): HourCycle { - const formatter = createLocaleDateTimeFormatter(locale, numberingSystem); +export function getLocaleHourFormat(locale: SupportedLocale): EffectiveHourFormat { + const options: DateTimeFormatterOptions = { locale }; + if (locale === "mk") { + // Chromium's Intl.DateTimeFormat incorrectly formats mk time to 12-hour cycle so we need to force hour12 to false + // @see https://issues.chromium.org/issues/40676973 + options.hour12 = false; + } else if (locale.toLowerCase() === "es-mx") { + // Firefox incorrectly formats es-MX time to 24-hour (should be 12) + // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1919656 + options.hour12 = true; + } + const formatter = createLocaleDateTimeFormatter(options); const parts = formatter.formatToParts(new Date(Date.UTC(0, 0, 0, 0, 0, 0))); return getLocalizedTimePart("meridiem", parts) ? "12" : "24"; } -export function getLocalizedDecimalSeparator(locale: string, numberingSystem: NumberingSystem): string { +export function getLocaleOppositeHourFormat(locale: SupportedLocale): EffectiveHourFormat { + const localeDefaultHourFormat = getLocaleHourFormat(locale); + if (localeDefaultHourFormat === "24") { + return "12"; + } + return "24"; +} + +/** + * To reference the CLDR meridiems for each supported locale navigate to: + * https://github.com/unicode-org/cldr-json/tree/main/cldr-json/cldr-dates-full/main, + * click {locale}/ca-generic.json and drill down to main.{locale}.dates.calendars.generic.dayPeriods.format.abbreviated. + * + * @param locale + * @param meridiem + * @param numberingSystem + */ +export function getLocalizedMeridiem( + locale: SupportedLocale, + meridiem: Meridiem, + numberingSystem: NumberingSystem = "latn", +): string { + const formatter = createLocaleDateTimeFormatter({ hour12: true, locale, numberingSystem }); + const arbitraryAMHour = 6; + const arbitraryPMHour = 18; + const dateWithHourBasedOnMeridiem = new Date( + Date.UTC(0, 0, 0, meridiem === "AM" ? arbitraryAMHour : arbitraryPMHour, 0), + ); + const parts = formatter.formatToParts(dateWithHourBasedOnMeridiem); + return getLocalizedTimePart("meridiem" as TimePart, parts); +} + +export function getLocalizedDecimalSeparator(locale: SupportedLocale, numberingSystem: NumberingSystem): string { numberStringFormatter.numberFormatOptions = { locale, numberingSystem, @@ -124,10 +189,10 @@ export function getLocalizedDecimalSeparator(locale: string, numberingSystem: Nu export function getLocalizedTimePartSuffix( part: "hour" | "minute" | "second", - locale: string, + locale: SupportedLocale, numberingSystem: NumberingSystem = "latn", ): string { - const formatter = createLocaleDateTimeFormatter(locale, numberingSystem); + const formatter = createLocaleDateTimeFormatter({ locale, numberingSystem }); const parts = formatter.formatToParts(new Date(Date.UTC(0, 0, 0, 0, 0, 0))); return getLocalizedTimePart(`${part}Suffix` as TimePart, parts); } @@ -168,12 +233,35 @@ export function getMeridiem(hour: string): Meridiem { return hourAsNumber >= 0 && hourAsNumber <= 11 ? "AM" : "PM"; } -export function getMeridiemOrder(locale: string): number { - const isRtl = locale === "ar" || locale === "he"; - if (isRtl) { - return 0; +export function getMeridiemFormatToken(locale: SupportedLocale): "a" | "A" | "a " | " a" | " A" | "A " { + const localizedAM = getLocalizedMeridiem(locale, "AM"); + const localizedPM = getLocalizedMeridiem(locale, "PM"); + const meridiemOrder = getMeridiemOrder(locale); + const timeParts = getTimeParts({ + hour12: true, + value: "00:00:00", + locale, + numberingSystem: "latn", + }); + const separator = + timeParts[meridiemOrder === 0 ? 1 : meridiemOrder - 1].type === "hour" || + timeParts[meridiemOrder - 1]?.type === "second" + ? "" + : " "; + if ( + // Unknown dayjs parsing bug with norwegian. Dayjs only accepts uppercase meridiems for some reason, despite the LT/LTS config + locale !== "no" && + localizedAM === localizedAM.toLocaleLowerCase(locale) && + localizedPM === localizedPM.toLocaleLowerCase(locale) + ) { + return meridiemOrder === 0 ? `a${separator}` : `${separator}a`; } + return meridiemOrder === 0 ? `A${separator}` : `${separator}A`; +} + +export function getMeridiemOrder(locale: SupportedLocale): number { const timeParts = getTimeParts({ + hour12: true, value: "00:00:00", locale, numberingSystem: "latn", @@ -181,6 +269,10 @@ export function getMeridiemOrder(locale: string): number { return timeParts.findIndex((value) => value.type === "dayPeriod"); } +export function isLocaleHourFormatOpposite(hourFormat: EffectiveHourFormat, locale: SupportedLocale): boolean { + return hourFormat === getLocaleOppositeHourFormat(locale); +} + export function isValidTime(value: string): boolean { if (!value || value.startsWith(":") || value.endsWith(":")) { return false; @@ -216,11 +308,16 @@ function isValidTimePart(value: string, part: TimePart): boolean { interface LocalizeTimePartParameters { value: string; part: TimePart; - locale: string; - numberingSystem: NumberingSystem; + locale: SupportedLocale; + numberingSystem?: NumberingSystem; } -export function localizeTimePart({ value, part, locale, numberingSystem }: LocalizeTimePartParameters): string { +export function localizeTimePart({ + value, + part, + locale, + numberingSystem = "latn", +}: LocalizeTimePartParameters): string { if (part === "fractionalSecond") { const localizedDecimalSeparator = getLocalizedDecimalSeparator(locale, numberingSystem); let localizedFractionalSecond = null; @@ -261,7 +358,7 @@ export function localizeTimePart({ value, part, locale, numberingSystem }: Local if (!date) { return; } - const formatter = createLocaleDateTimeFormatter(locale, numberingSystem); + const formatter = createLocaleDateTimeFormatter({ locale, numberingSystem }); const parts = formatter.formatToParts(date); return getLocalizedTimePart(part, parts); } @@ -270,16 +367,18 @@ interface LocalizeTimeStringParameters { value: string; includeSeconds?: boolean; fractionalSecondDigits?: FractionalSecondDigits; - locale: string; - numberingSystem: NumberingSystem; + locale: SupportedLocale; + numberingSystem?: NumberingSystem; + hour12?: boolean; } export function localizeTimeString({ value, locale, - numberingSystem, + numberingSystem = "latn", includeSeconds = true, fractionalSecondDigits, + hour12, }: LocalizeTimeStringParameters): string { if (!isValidTime(value)) { return null; @@ -297,20 +396,51 @@ export function localizeTimeString({ fractionalSecond && fractionalSecondPartToMilliseconds(fractionalSecond), ), ); - const formatter = createLocaleDateTimeFormatter(locale, numberingSystem, includeSeconds, fractionalSecondDigits); - return formatter.format(dateFromTimeString) || null; + const formatter = createLocaleDateTimeFormatter({ + locale, + numberingSystem, + includeSeconds, + fractionalSecondDigits, + hour12, + }); + let result = formatter.format(dateFromTimeString) || null; + + // The bulgarian "ч." character (abbreviation for "hours") should not display for short and medium time formats. + if (result && locale === "bg" && result.includes(" ч.")) { + result = result.replaceAll(" ч.", ""); + } + + // Chromium doesn't return correct localized meridiem for Bosnian or Macedonian. + // @see https://issues.chromium.org/issues/40172622 + // @see https://issues.chromium.org/issues/40676973 + if (locale === "bs" || locale === "mk") { + const localeData = localizedTwentyFourHourMeridiems.get(locale); + if (result.includes("AM")) { + result = result.replaceAll("AM", localeData.am); + } else if (result.includes("PM")) { + result = result.replaceAll("PM", localeData.pm); + } + // This ensures just the decimal separator is replaced and not the period at the end of Macedonian meridiems. + if (result.indexOf(".") !== result.length - 1) { + result = result.replace(".", ","); + } + } + + return result; } interface LocalizeTimeStringToPartsParameters { value: string; - locale: string; + locale: SupportedLocale; numberingSystem?: NumberingSystem; + hour12?: boolean; } export function localizeTimeStringToParts({ value, locale, numberingSystem = "latn", + hour12, }: LocalizeTimeStringToPartsParameters): LocalizedTime { if (!isValidTime(value)) { return null; @@ -318,8 +448,18 @@ export function localizeTimeStringToParts({ const { hour, minute, second = "0", fractionalSecond } = parseTimeString(value); const dateFromTimeString = new Date(Date.UTC(0, 0, 0, parseInt(hour), parseInt(minute), parseInt(second))); if (dateFromTimeString) { - const formatter = createLocaleDateTimeFormatter(locale, numberingSystem); + const formatter = createLocaleDateTimeFormatter({ locale, numberingSystem, hour12 }); const parts = formatter.formatToParts(dateFromTimeString); + let localizedMeridiem = getLocalizedTimePart("meridiem", parts); + + // Chromium doesn't return correct localized meridiem for Bosnian or Macedonian. + // @see https://issues.chromium.org/issues/40172622 + // @see https://issues.chromium.org/issues/40676973 + if (hour12 && (locale === "bs" || locale === "mk")) { + const localeData = localizedTwentyFourHourMeridiems.get(locale); + localizedMeridiem = dateFromTimeString.getHours() > 11 ? localeData.am : localeData.pm; + } + return { localizedHour: getLocalizedTimePart("hour", parts), localizedHourSuffix: getLocalizedTimePart("hourSuffix", parts), @@ -334,25 +474,31 @@ export function localizeTimeStringToParts({ numberingSystem, }), localizedSecondSuffix: getLocalizedTimePart("secondSuffix", parts), - localizedMeridiem: getLocalizedTimePart("meridiem", parts), + localizedMeridiem, }; } return null; } interface GetTimePartsParameters { + hour12?: boolean; value: string; - locale: string; + locale: SupportedLocale; numberingSystem: NumberingSystem; } -export function getTimeParts({ value, locale, numberingSystem }: GetTimePartsParameters): Intl.DateTimeFormatPart[] { +export function getTimeParts({ + hour12, + value, + locale, + numberingSystem, +}: GetTimePartsParameters): Intl.DateTimeFormatPart[] { if (!isValidTime(value)) { return null; } const { hour, minute, second = "0" } = parseTimeString(value); const dateFromTimeString = new Date(Date.UTC(0, 0, 0, parseInt(hour), parseInt(minute), parseInt(second))); if (dateFromTimeString) { - const formatter = createLocaleDateTimeFormatter(locale, numberingSystem); + const formatter = createLocaleDateTimeFormatter({ hour12, locale, numberingSystem }); const parts = formatter.formatToParts(dateFromTimeString); return parts; }