From c2bf34b69c6c199c4709774b2d80b71d3b2f9b9e Mon Sep 17 00:00:00 2001 From: Erik Harper Date: Wed, 30 Aug 2023 18:49:22 -0700 Subject: [PATCH] feat(input-time-picker): support fractional seconds (#7532) **Related Issue:** #6591 ## Summary This PR adds initial support for fractional seconds in `input-time-picker` and `time-picker` components. Both `input-time-picker` and `time-picker` officially support fractional step values from `.001` to `.9`. --------- Co-authored-by: Erik Harper --- .../input-time-picker.stories.ts | 42 +++ .../input-time-picker/input-time-picker.tsx | 175 ++++++++-- .../input-time-picker/usage/Basic.md | 8 +- .../usage/Fractional-seconds.md | 3 + .../assets/time-picker/t9n/messages.json | 3 + .../assets/time-picker/t9n/messages_en.json | 3 + .../src/components/time-picker/resources.ts | 3 + .../components/time-picker/time-picker.e2e.ts | 45 +++ .../time-picker/time-picker.stories.ts | 29 ++ .../components/time-picker/time-picker.tsx | 326 +++++++++++++----- .../src/demos/input-time-picker.html | 19 + .../calcite-components/src/utils/math.spec.ts | 26 ++ packages/calcite-components/src/utils/math.ts | 20 +- .../calcite-components/src/utils/time.spec.ts | 196 ++++++++++- packages/calcite-components/src/utils/time.ts | 158 +++++++-- 15 files changed, 912 insertions(+), 144 deletions(-) create mode 100644 packages/calcite-components/src/components/input-time-picker/usage/Fractional-seconds.md create mode 100644 packages/calcite-components/src/components/time-picker/time-picker.stories.ts 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 c58275bb4c2..2d462c6f2f5 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 @@ -26,6 +26,48 @@ export const simple = (): string => html` `; +export const deciSeconds_TestOnly = (): string => html` + + +`; + +export const centiseconds_TestOnly = (): string => html` + + +`; + +export const milliseconds_TestOnly = (): string => html` + + +`; + export const disabled_TestOnly = (): string => html``; diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx index 20649d58ed0..85308abd210 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx @@ -50,7 +50,9 @@ import { } from "../../utils/focusTrapComponent"; import { FocusTrap } from "focus-trap"; import { + formatTimePart, formatTimeString, + FractionalSecondDigits, isValidTime, localizeTimeString, toISOTimeString, @@ -68,9 +70,10 @@ import localizedFormat from "dayjs/esm/plugin/localizedFormat"; import preParsePostFormat from "dayjs/esm/plugin/preParsePostFormat"; import updateLocale from "dayjs/esm/plugin/updateLocale"; import { getSupportedLocale } from "../../utils/locale"; +import { decimalPlaces } from "../../utils/math"; // some bundlers (e.g., Webpack) need dynamic import paths to be static -const supportedDayJsLocaleToLocaleConfigImport = new Map([ +const supportedDayjsLocaleToLocaleConfigImport = new Map([ ["ar", () => import("dayjs/esm/locale/ar.js")], ["bg", () => import("dayjs/esm/locale/bg.js")], ["bs", () => import("dayjs/esm/locale/bs.js")], @@ -129,6 +132,13 @@ dayjs.extend(localizedFormat); dayjs.extend(preParsePostFormat); dayjs.extend(updateLocale); +interface DayjsTimeParts { + hour: number; + minute: number; + second: number; + millisecond: number; +} + @Component({ tag: "calcite-input-time-picker", styleUrl: "input-time-picker.scss", @@ -244,6 +254,7 @@ export class InputTimePicker locale: this.effectiveLocale, numberingSystem, includeSeconds: this.shouldIncludeSeconds(), + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, }) ); } @@ -323,6 +334,8 @@ export class InputTimePicker private dialogId = `time-picker-dialog--${guid()}`; + private localeConfig: ILocale; + /** whether the value of the input was changed as a result of user typing or not */ private userChangedValue = false; @@ -347,6 +360,7 @@ export class InputTimePicker locale, numberingSystem: this.numberingSystem, includeSeconds: this.shouldIncludeSeconds(), + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, }) ); } @@ -386,6 +400,7 @@ export class InputTimePicker locale: this.effectiveLocale, numberingSystem: this.numberingSystem, includeSeconds: this.shouldIncludeSeconds(), + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, }); if (localizedTimeString !== inputValue) { @@ -439,6 +454,7 @@ export class InputTimePicker locale: this.effectiveLocale, numberingSystem: this.numberingSystem, includeSeconds, + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, }) ); }; @@ -476,20 +492,106 @@ export class InputTimePicker // we need to set the corresponding locale before parsing, otherwise it defaults to English (possible dayjs bug) dayjs.locale(this.effectiveLocale.toLowerCase()); - const dayjsParseResult = dayjs(value, ["LTS", "LT"]); + const nonFractionalSecondParts = this.delocalizeTimeStringToParts(value); + + let delocalizedTimeString; + + if (this.shouldIncludeFractionalSeconds()) { + const stepPrecision = decimalPlaces(this.step); + const centisecondParts = this.delocalizeTimeStringToParts(value, "S"); + + if (stepPrecision === 1) { + delocalizedTimeString = + centisecondParts.millisecond !== 0 + ? this.getTimeStringFromParts(centisecondParts) + : this.getTimeStringFromParts(nonFractionalSecondParts); + } else { + const decisecondParts = this.delocalizeTimeStringToParts(value, "SS"); + + if (stepPrecision === 2) { + if (decisecondParts.millisecond !== 0) { + delocalizedTimeString = this.getTimeStringFromParts(decisecondParts); + } else if (centisecondParts.millisecond !== 0) { + delocalizedTimeString = this.getTimeStringFromParts(centisecondParts); + } else { + delocalizedTimeString = this.getTimeStringFromParts(nonFractionalSecondParts); + } + } else if (stepPrecision >= 3) { + const millisecondParts = this.delocalizeTimeStringToParts(value, "SSS"); + + if (millisecondParts.millisecond !== 0) { + delocalizedTimeString = this.getTimeStringFromParts(millisecondParts); + } else if (decisecondParts.millisecond !== 0) { + delocalizedTimeString = this.getTimeStringFromParts(decisecondParts); + } else if (centisecondParts.millisecond !== 0) { + delocalizedTimeString = this.getTimeStringFromParts(centisecondParts); + } else { + delocalizedTimeString = this.getTimeStringFromParts(nonFractionalSecondParts); + } + } + } + } else { + delocalizedTimeString = this.getTimeStringFromParts(nonFractionalSecondParts); + } + + return delocalizedTimeString; + } + + private delocalizeTimeStringToParts( + localizedTimeString: 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"); + } + + dayjs.updateLocale( + this.getSupportedDayjsLocale(getSupportedLocale(this.effectiveLocale)), + this.localeConfig as Record + ); + + const dayjsParseResult = dayjs(localizedTimeString, ["LTS", "LT"]); if (dayjsParseResult.isValid()) { - let unformattedTimeString = `${dayjsParseResult.get("hour")}:${dayjsParseResult.get( - "minute" - )}`; + return { + hour: dayjsParseResult.get("hour"), + minute: dayjsParseResult.get("minute"), + second: dayjsParseResult.get("second"), + millisecond: dayjsParseResult.get("millisecond"), + }; + } + return { + hour: null, + minute: null, + second: null, + millisecond: null, + }; + } - if (this.shouldIncludeSeconds()) { - unformattedTimeString += `:${dayjsParseResult.get("seconds") || 0}`; + private getTimeStringFromParts(parts: DayjsTimeParts): string { + let timeString = ""; + if (!parts) { + return timeString; + } + if (parts.hour !== null && parts.minute !== null) { + timeString = `${formatTimePart(parts.hour)}:${formatTimePart(parts.minute)}`; + if (this.shouldIncludeSeconds() && parts.second !== null) { + timeString += `:${formatTimePart(parts.second)}`; + if (this.shouldIncludeFractionalSeconds() && parts.millisecond !== null) { + const second = (parts.millisecond * 0.001).toFixed(decimalPlaces(this.step)); + timeString += `.${second.toString().replace("0.", "")}`; + } } - - return formatTimeString(unformattedTimeString) || ""; } - return ""; + return timeString; } private popoverCloseHandler = () => { @@ -531,17 +633,20 @@ export class InputTimePicker const newValue = this.delocalizeTimeString(this.calciteInputEl.value); - this.setValue(newValue); + if (isValidTime(newValue)) { + this.setValue(newValue); - const localizedTimeString = localizeTimeString({ - value: this.value, - locale: this.effectiveLocale, - numberingSystem: this.numberingSystem, - includeSeconds: this.shouldIncludeSeconds(), - }); + const localizedTimeString = localizeTimeString({ + value: this.value, + locale: this.effectiveLocale, + numberingSystem: this.numberingSystem, + includeSeconds: this.shouldIncludeSeconds(), + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, + }); - if (newValue && this.calciteInputEl.value !== localizedTimeString) { - this.setInputValue(localizedTimeString); + if (newValue && this.calciteInputEl.value !== localizedTimeString) { + this.setInputValue(localizedTimeString); + } } } else if (key === "ArrowDown") { this.open = true; @@ -554,22 +659,29 @@ export class InputTimePicker } }; + private getSupportedDayjsLocale(locale: string) { + const dayjsLocale = locale.toLowerCase(); + if (dayjsLocale === "no") { + return "nb"; + } + if (dayjsLocale === "pt-pt") { + return "pt"; + } + return dayjsLocale; + } + private async loadDateTimeLocaleData(): Promise { let supportedLocale = getSupportedLocale(this.effectiveLocale).toLowerCase(); - if (supportedLocale === "no") { - supportedLocale = "nb"; - } - - if (supportedLocale === "pt-pt") { - supportedLocale = "pt"; - } + supportedLocale = this.getSupportedDayjsLocale(supportedLocale); - const { default: localeConfig } = await supportedDayJsLocaleToLocaleConfigImport.get( + const { default: localeConfig } = await supportedDayjsLocaleToLocaleConfigImport.get( supportedLocale )(); - dayjs.locale(localeConfig, null, true); + this.localeConfig = localeConfig; + + dayjs.locale(this.localeConfig, null, true); dayjs.updateLocale(supportedLocale, this.getExtendedLocaleConfig(supportedLocale)); } @@ -656,6 +768,10 @@ export class InputTimePicker return this.step < 60; } + private shouldIncludeFractionalSeconds(): boolean { + return decimalPlaces(this.step) > 0; + } + private setCalcitePopoverEl = (el: HTMLCalcitePopoverElement): void => { this.popoverEl = el; }; @@ -710,6 +826,7 @@ export class InputTimePicker locale: this.effectiveLocale, numberingSystem: this.numberingSystem, includeSeconds: this.shouldIncludeSeconds(), + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, }) ); } @@ -731,6 +848,7 @@ export class InputTimePicker includeSeconds, locale: this.effectiveLocale, numberingSystem: this.numberingSystem, + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, }) : "" ); @@ -779,6 +897,7 @@ export class InputTimePicker locale: this.effectiveLocale, numberingSystem: this.numberingSystem, includeSeconds: this.shouldIncludeSeconds(), + fractionalSecondDigits: decimalPlaces(this.step) as FractionalSecondDigits, }) ); } diff --git a/packages/calcite-components/src/components/input-time-picker/usage/Basic.md b/packages/calcite-components/src/components/input-time-picker/usage/Basic.md index b65954ce2e3..117000f7e33 100644 --- a/packages/calcite-components/src/components/input-time-picker/usage/Basic.md +++ b/packages/calcite-components/src/components/input-time-picker/usage/Basic.md @@ -1,9 +1,3 @@ ```html - + ``` diff --git a/packages/calcite-components/src/components/input-time-picker/usage/Fractional-seconds.md b/packages/calcite-components/src/components/input-time-picker/usage/Fractional-seconds.md new file mode 100644 index 00000000000..5bf80c2ea63 --- /dev/null +++ b/packages/calcite-components/src/components/input-time-picker/usage/Fractional-seconds.md @@ -0,0 +1,3 @@ +```html + +``` diff --git a/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages.json b/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages.json index 00f28802e29..4b214d9b59e 100644 --- a/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages.json +++ b/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages.json @@ -1,4 +1,7 @@ { + "fractionalSecond": "Fractional second", + "fractionalSecondDown": "Decrease fractional second", + "fractionalSecondUp": "Increase fractional second", "hour": "Hour", "hourDown": "Decrease hour", "hourUp": "Increase hour", diff --git a/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages_en.json b/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages_en.json index 00f28802e29..4b214d9b59e 100644 --- a/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages_en.json +++ b/packages/calcite-components/src/components/time-picker/assets/time-picker/t9n/messages_en.json @@ -1,4 +1,7 @@ { + "fractionalSecond": "Fractional second", + "fractionalSecondDown": "Decrease fractional second", + "fractionalSecondUp": "Increase fractional second", "hour": "Hour", "hourDown": "Decrease hour", "hourUp": "Increase hour", diff --git a/packages/calcite-components/src/components/time-picker/resources.ts b/packages/calcite-components/src/components/time-picker/resources.ts index 19679137c0d..32834f0f5eb 100644 --- a/packages/calcite-components/src/components/time-picker/resources.ts +++ b/packages/calcite-components/src/components/time-picker/resources.ts @@ -2,6 +2,8 @@ export const CSS = { button: "button", buttonBottomLeft: "button--bottom-left", buttonBottomRight: "button--bottom-right", + buttonFractionalSecondDown: "button--fractionalSecond-down", + buttonFractionalSecondUp: "button--fractionalSecond-up", buttonHourDown: "button--hour-down", buttonHourUp: "button--hour-up", buttonMeridiemDown: "button--meridiem-down", @@ -14,6 +16,7 @@ export const CSS = { buttonTopRight: "button--top-right", column: "column", delimiter: "delimiter", + fractionalSecond: "fractionalSecond", hour: "hour", input: "input", meridiem: "meridiem", diff --git a/packages/calcite-components/src/components/time-picker/time-picker.e2e.ts b/packages/calcite-components/src/components/time-picker/time-picker.e2e.ts index 48f246fb47c..8b531d5ef46 100644 --- a/packages/calcite-components/src/components/time-picker/time-picker.e2e.ts +++ b/packages/calcite-components/src/components/time-picker/time-picker.e2e.ts @@ -2,6 +2,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, defaults, focusable, hidden, renders, t9n } from "../../tests/commonTests"; import { formatTimePart } from "../../utils/time"; import { CSS } from "./resources"; +import { getElementXY } from "../../tests/utils"; const letterKeys = [ "a", @@ -1091,4 +1092,48 @@ describe("calcite-time-picker", () => { expect(await page.find(`calcite-time-picker >>> .${CSS.second}`)).toBeNull(); }); + + describe("fractional second support", () => { + it("upward nudge of empty fractional second sets to 0 for step=0.1", async () => { + const page = await newE2EPage(); + await page.setContent(``); + const [buttonUpLocationX, buttonUpLocationY] = await getElementXY( + page, + "calcite-time-picker", + ".button--fractionalSecond-up" + ); + await page.mouse.click(buttonUpLocationX, buttonUpLocationY); + await page.waitForChanges(); + const fractionalSecondEl = await page.find(`calcite-time-picker >>> .input.fractionalSecond`); + expect(fractionalSecondEl.innerHTML).toEqual("0"); + }); + + it("upward nudge of empty fractional second sets to 00 for step=0.01", async () => { + const page = await newE2EPage(); + await page.setContent(``); + const [buttonUpLocationX, buttonUpLocationY] = await getElementXY( + page, + "calcite-time-picker", + ".button--fractionalSecond-up" + ); + await page.mouse.click(buttonUpLocationX, buttonUpLocationY); + await page.waitForChanges(); + const fractionalSecondEl = await page.find(`calcite-time-picker >>> .input.fractionalSecond`); + expect(fractionalSecondEl.innerHTML).toEqual("00"); + }); + + it("upward nudge of empty fractional second sets to 000 for step=0.001", async () => { + const page = await newE2EPage(); + await page.setContent(``); + const [buttonUpLocationX, buttonUpLocationY] = await getElementXY( + page, + "calcite-time-picker", + ".button--fractionalSecond-up" + ); + await page.mouse.click(buttonUpLocationX, buttonUpLocationY); + await page.waitForChanges(); + const fractionalSecondEl = await page.find(`calcite-time-picker >>> .input.fractionalSecond`); + expect(fractionalSecondEl.innerHTML).toEqual("000"); + }); + }); }); diff --git a/packages/calcite-components/src/components/time-picker/time-picker.stories.ts b/packages/calcite-components/src/components/time-picker/time-picker.stories.ts new file mode 100644 index 00000000000..e688e6a9b30 --- /dev/null +++ b/packages/calcite-components/src/components/time-picker/time-picker.stories.ts @@ -0,0 +1,29 @@ +import { number, select, text } from "@storybook/addon-knobs"; +import { boolean, storyFilters } from "../../../.storybook/helpers"; +import readme from "./readme.md"; +import { html } from "../../../support/formatting"; +import { defaultMenuPlacement, menuPlacements } from "../../utils/floating-ui"; +import { locales, numberingSystems } from "../../utils/locale"; + +export default { + title: "Components/Controls/Time/Time Picker", + parameters: { + notes: readme, + }, + ...storyFilters(), +}; + +export const simple = (): string => html` + + +`; 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 5b57ce14e47..e245d5197af 100644 --- a/packages/calcite-components/src/components/time-picker/time-picker.tsx +++ b/packages/calcite-components/src/components/time-picker/time-picker.tsx @@ -11,7 +11,7 @@ import { VNode, Watch, } from "@stencil/core"; -import { isActivationKey, numberKeys } from "../../utils/key"; +import { numberKeys } from "../../utils/key"; import { isValidNumber } from "../../utils/number"; import { Scale } from "../interfaces"; @@ -31,6 +31,8 @@ import { import { formatTimePart, getLocaleHourCycle, + getLocalizedDecimalSeparator, + getLocalizedTimePartSuffix, getMeridiem, getTimeParts, HourCycle, @@ -52,6 +54,7 @@ import { setComponentLoaded, setUpLoadableComponent, } from "../../utils/loadable"; +import { decimalPlaces, getDecimals } from "../../utils/math"; function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); @@ -82,7 +85,7 @@ export class TimePicker @Watch("step") stepChange(): void { - this.updateShowSecond(); + this.toggleSecond(); } /** @@ -96,7 +99,7 @@ export class TimePicker @Watch("value") valueWatcher(newValue: string): void { - this.setValue(newValue, false); + this.setValue(newValue); } /** @@ -128,16 +131,18 @@ export class TimePicker private activeEl: HTMLSpanElement; + private fractionalSecondEl: HTMLSpanElement; + private hourEl: HTMLSpanElement; private meridiemEl: HTMLSpanElement; + private meridiemOrder: number; + private minuteEl: HTMLSpanElement; private secondEl: HTMLSpanElement; - private meridiemOrder: number; - // -------------------------------------------------------------------------- // // State @@ -151,16 +156,22 @@ export class TimePicker this.updateLocale(); } + @State() fractionalSecond: string; + @State() hour: string; @State() hourCycle: HourCycle; + @State() localizedDecimalSeparator = "."; + @State() localizedHour: string; @State() localizedHourSuffix: string; @State() localizedMeridiem: string; + @State() localizedFractionalSecond: string; + @State() localizedMinute: string; @State() localizedMinuteSuffix: string; @@ -175,6 +186,8 @@ export class TimePicker @State() second: string; + @State() showFractionalSecond: boolean; + @State() showSecond: boolean; @State() defaultMessages: TimePickerMessages; @@ -254,6 +267,22 @@ export class TimePicker this.focusPart("minute"); event.preventDefault(); break; + case "ArrowRight": + if (this.showFractionalSecond) { + this.focusPart("fractionalSecond"); + } else if (this.hourCycle === "12") { + this.focusPart("meridiem"); + event.preventDefault(); + } + break; + } + break; + case this.fractionalSecondEl: + switch (key) { + case "ArrowLeft": + this.focusPart("second"); + event.preventDefault(); + break; case "ArrowRight": if (this.hourCycle === "12") { this.focusPart("meridiem"); @@ -265,7 +294,9 @@ export class TimePicker case this.meridiemEl: switch (key) { case "ArrowLeft": - if (this.step !== 60) { + if (this.showFractionalSecond) { + this.focusPart("fractionalSecond"); + } else if (this.step !== 60) { this.focusPart("second"); event.preventDefault(); } else { @@ -300,25 +331,15 @@ export class TimePicker // // -------------------------------------------------------------------------- - private updateShowSecond(): void { - this.showSecond = this.step < 60; - } - private async focusPart(target: TimePart): Promise { await componentFocusable(this); this[`${target || "hour"}El`]?.focus(); } - private buttonActivated(event: KeyboardEvent): boolean { - const { key } = event; - - if (key === " ") { - event.preventDefault(); - } - - return isActivationKey(key); - } + private decrementFractionalSecond = (): void => { + this.nudgeFractionalSecond("down"); + }; private decrementHour = (): void => { const newHour = !this.hour ? 0 : this.hour === "00" ? 23 : parseInt(this.hour) - 1; @@ -353,9 +374,43 @@ export class TimePicker this.activeEl = event.currentTarget as HTMLSpanElement; }; - private hourDownButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.decrementHour(); + private fractionalSecondKeyDownHandler = (event: KeyboardEvent): void => { + const { key } = event; + if (numberKeys.includes(key)) { + const stepPrecision = decimalPlaces(this.step); + const fractionalSecondAsInteger = parseInt(this.fractionalSecond); + const fractionalSecondAsIntegerLength = fractionalSecondAsInteger.toString().length; + + let newFractionalSecondAsIntegerString; + + if (fractionalSecondAsIntegerLength >= stepPrecision) { + newFractionalSecondAsIntegerString = key.padStart(stepPrecision, "0"); + } else if (fractionalSecondAsIntegerLength < stepPrecision) { + newFractionalSecondAsIntegerString = `${fractionalSecondAsInteger}${key}`.padStart( + stepPrecision, + "0" + ); + } + + this.setValuePart("fractionalSecond", parseFloat(`0.${newFractionalSecondAsIntegerString}`)); + } else { + switch (key) { + case "Backspace": + case "Delete": + this.setValuePart("fractionalSecond", null); + break; + case "ArrowDown": + event.preventDefault(); + this.nudgeFractionalSecond("down"); + break; + case "ArrowUp": + event.preventDefault(); + this.nudgeFractionalSecond("up"); + break; + case " ": + event.preventDefault(); + break; + } } }; @@ -407,10 +462,8 @@ export class TimePicker } }; - private hourUpButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.incrementHour(); - } + private incrementFractionalSecond = (): void => { + this.nudgeFractionalSecond("up"); }; private incrementMeridiem = (): void => { @@ -444,12 +497,6 @@ export class TimePicker this.incrementMinuteOrSecond("second"); }; - private meridiemDownButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.decrementMeridiem(); - } - }; - private meridiemKeyDownHandler = (event: KeyboardEvent): void => { switch (event.key) { case "a": @@ -476,18 +523,6 @@ export class TimePicker } }; - private meridiemUpButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.incrementMeridiem(); - } - }; - - private minuteDownButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.decrementMinute(); - } - }; - private minuteKeyDownHandler = (event: KeyboardEvent): void => { const { key } = event; if (numberKeys.includes(key)) { @@ -524,18 +559,55 @@ export class TimePicker } }; - private minuteUpButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.incrementMinute(); + private nudgeFractionalSecond = (direction: "up" | "down"): void => { + const stepDecimal = getDecimals(this.step); + const stepPrecision = decimalPlaces(this.step); + const fractionalSecondAsInteger = parseInt(this.fractionalSecond); + const fractionalSecondAsFloat = parseFloat(`0.${this.fractionalSecond}`); + let nudgedValue; + let nudgedValueRounded; + let nudgedValueRoundedDecimals; + let newFractionalSecond; + if (direction === "up") { + nudgedValue = isNaN(fractionalSecondAsInteger) ? 0 : fractionalSecondAsFloat + stepDecimal; + nudgedValueRounded = parseFloat(nudgedValue.toFixed(stepPrecision)); + nudgedValueRoundedDecimals = getDecimals(nudgedValueRounded); + newFractionalSecond = + nudgedValueRounded < 1 && decimalPlaces(nudgedValueRoundedDecimals) > 0 + ? formatTimePart(nudgedValueRoundedDecimals, stepPrecision) + : "".padStart(stepPrecision, "0"); } + if (direction === "down") { + nudgedValue = + isNaN(fractionalSecondAsInteger) || fractionalSecondAsInteger === 0 + ? 1 - stepDecimal + : fractionalSecondAsFloat - stepDecimal; + nudgedValueRounded = parseFloat(nudgedValue.toFixed(stepPrecision)); + nudgedValueRoundedDecimals = getDecimals(nudgedValueRounded); + newFractionalSecond = + nudgedValueRounded < 1 && + decimalPlaces(nudgedValueRoundedDecimals) > 0 && + Math.sign(nudgedValueRoundedDecimals) === 1 + ? formatTimePart(nudgedValueRoundedDecimals, stepPrecision) + : "".padStart(stepPrecision, "0"); + } + this.setValuePart("fractionalSecond", newFractionalSecond); }; - private secondDownButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.decrementSecond(); + private sanitizeValue = (value: string): string => { + const { hour, minute, second, fractionalSecond } = parseTimeString(value); + if (fractionalSecond) { + const sanitizedFractionalSecond = this.sanitizeFractionalSecond(fractionalSecond); + return `${hour}:${minute}:${second}.${sanitizedFractionalSecond}`; } + return isValidTime(value) && value; }; + private sanitizeFractionalSecond = (fractionalSecond: string): string => + fractionalSecond && decimalPlaces(this.step) !== fractionalSecond.length + ? parseFloat(`0.${fractionalSecond}`).toFixed(decimalPlaces(this.step)).replace("0.", "") + : fractionalSecond; + private secondKeyDownHandler = (event: KeyboardEvent): void => { const { key } = event; if (numberKeys.includes(key)) { @@ -572,12 +644,6 @@ export class TimePicker } }; - private secondUpButtonKeyDownHandler = (event: KeyboardEvent): void => { - if (this.buttonActivated(event)) { - this.incrementSecond(); - } - }; - private setHourEl = (el: HTMLSpanElement) => (this.hourEl = el); private setMeridiemEl = (el: HTMLSpanElement) => (this.meridiemEl = el); @@ -586,9 +652,11 @@ export class TimePicker private setSecondEl = (el: HTMLSpanElement) => (this.secondEl = el); - private setValue = (value: string, emit = true): void => { + private setFractionalSecondEl = (el: HTMLSpanElement) => (this.fractionalSecondEl = el); + + private setValue = (value: string): void => { if (isValidTime(value)) { - const { hour, minute, second } = parseTimeString(value); + const { hour, minute, second, fractionalSecond } = parseTimeString(value); const { effectiveLocale: locale, numberingSystem } = this; const { localizedHour, @@ -596,18 +664,23 @@ export class TimePicker localizedMinute, localizedMinuteSuffix, localizedSecond, + localizedDecimalSeparator, + localizedFractionalSecond, localizedSecondSuffix, localizedMeridiem, } = localizeTimeStringToParts({ value, locale, numberingSystem }); + this.hour = hour; + this.minute = minute; + this.second = second; + this.fractionalSecond = this.sanitizeFractionalSecond(fractionalSecond); this.localizedHour = localizedHour; this.localizedHourSuffix = localizedHourSuffix; this.localizedMinute = localizedMinute; this.localizedMinuteSuffix = localizedMinuteSuffix; this.localizedSecond = localizedSecond; + this.localizedDecimalSeparator = localizedDecimalSeparator; + this.localizedFractionalSecond = localizedFractionalSecond; this.localizedSecondSuffix = localizedSecondSuffix; - this.hour = hour; - this.minute = minute; - this.second = second; if (localizedMeridiem) { this.localizedMeridiem = localizedMeridiem; this.meridiem = getMeridiem(this.hour); @@ -616,27 +689,41 @@ export class TimePicker } } else { this.hour = null; + this.fractionalSecond = null; this.localizedHour = null; - this.localizedHourSuffix = null; + this.localizedHourSuffix = getLocalizedTimePartSuffix( + "hour", + this.effectiveLocale, + this.numberingSystem + ); this.localizedMeridiem = null; this.localizedMinute = null; - this.localizedMinuteSuffix = null; + this.localizedMinuteSuffix = getLocalizedTimePartSuffix( + "minute", + this.effectiveLocale, + this.numberingSystem + ); this.localizedSecond = null; - this.localizedSecondSuffix = null; + this.localizedDecimalSeparator = getLocalizedDecimalSeparator( + this.effectiveLocale, + this.numberingSystem + ); + this.localizedFractionalSecond = null; + this.localizedSecondSuffix = getLocalizedTimePartSuffix( + "second", + this.effectiveLocale, + this.numberingSystem + ); this.meridiem = null; this.minute = null; this.second = null; this.value = null; } - if (emit) { - this.calciteInternalTimePickerChange.emit(); - } }; private setValuePart = ( - key: "hour" | "minute" | "second" | "meridiem", - value: number | string | Meridiem, - emit = true + key: "hour" | "minute" | "second" | "fractionalSecond" | "meridiem", + value: number | string | Meridiem ): void => { const { effectiveLocale: locale, numberingSystem } = this; if (key === "meridiem") { @@ -662,6 +749,20 @@ export class TimePicker numberingSystem, }); } + } else if (key === "fractionalSecond") { + const stepPrecision = decimalPlaces(this.step); + if (typeof value === "number") { + this.fractionalSecond = + value === 0 ? "".padStart(stepPrecision, "0") : formatTimePart(value, stepPrecision); + } else { + this.fractionalSecond = value; + } + this.localizedFractionalSecond = localizeTimePart({ + value: this.fractionalSecond, + part: "fractionalSecond", + locale, + numberingSystem, + }); } else { this[key] = typeof value === "number" ? formatTimePart(value) : value; this[`localized${capitalize(key)}`] = localizeTimePart({ @@ -671,15 +772,23 @@ export class TimePicker numberingSystem, }); } + let emit = false; + let newValue; if (this.hour && this.minute) { - let newValue = `${this.hour}:${this.minute}`; + newValue = `${this.hour}:${this.minute}`; if (this.showSecond) { newValue = `${newValue}:${this.second ?? "00"}`; + if (this.showFractionalSecond && this.fractionalSecond) { + newValue = `${newValue}.${this.fractionalSecond}`; + } } - this.value = newValue; } else { - this.value = null; + newValue = null; } + if (this.value !== newValue) { + emit = true; + } + this.value = newValue; this.localizedMeridiem = this.value ? localizeTimeStringToParts({ value: this.value, locale, numberingSystem }) ?.localizedMeridiem || null @@ -689,6 +798,11 @@ export class TimePicker } }; + private toggleSecond(): void { + this.showSecond = this.step < 60; + this.showFractionalSecond = decimalPlaces(this.step) > 0; + } + private getMeridiemOrder(formatParts: Intl.DateTimeFormatPart[]): number { const locale = this.effectiveLocale; const isRTLKind = locale === "ar" || locale === "he"; @@ -704,7 +818,11 @@ export class TimePicker private updateLocale() { updateMessages(this, this.effectiveLocale); this.hourCycle = getLocaleHourCycle(this.effectiveLocale, this.numberingSystem); - this.setValue(this.value, false); + this.localizedDecimalSeparator = getLocalizedDecimalSeparator( + this.effectiveLocale, + this.numberingSystem + ); + this.setValue(this.sanitizeValue(this.value)); } // -------------------------------------------------------------------------- @@ -717,7 +835,7 @@ export class TimePicker connectLocalized(this); this.updateLocale(); connectMessages(this); - this.updateShowSecond(); + this.toggleSecond(); this.meridiemOrder = this.getMeridiemOrder( getTimeParts({ value: "0:00:00", @@ -752,6 +870,7 @@ export class TimePicker const iconScale = this.scale === "s" || this.scale === "m" ? "s" : "m"; const minuteIsNumber = isValidNumber(this.minute); const secondIsNumber = isValidNumber(this.second); + const fractionalSecondIsNumber = isValidNumber(this.fractionalSecond); const showMeridiem = this.hourCycle === "12"; return (
@@ -804,7 +922,6 @@ export class TimePicker [CSS.buttonBottomLeft]: true, }} onClick={this.decrementHour} - onKeyDown={this.hourDownButtonKeyDownHandler} role="button" > @@ -819,9 +936,7 @@ export class TimePicker [CSS.buttonMinuteUp]: true, }} onClick={this.incrementMinute} - onKeyDown={this.minuteUpButtonKeyDownHandler} role="button" - tabIndex={-1} > @@ -851,7 +966,6 @@ export class TimePicker [CSS.buttonMinuteDown]: true, }} onClick={this.decrementMinute} - onKeyDown={this.minuteDownButtonKeyDownHandler} role="button" > @@ -867,7 +981,6 @@ export class TimePicker [CSS.buttonSecondUp]: true, }} onClick={this.incrementSecond} - onKeyDown={this.secondUpButtonKeyDownHandler} role="button" > @@ -898,7 +1011,54 @@ export class TimePicker [CSS.buttonSecondDown]: true, }} onClick={this.decrementSecond} - onKeyDown={this.secondDownButtonKeyDownHandler} + role="button" + > + + +
+ )} + {this.showFractionalSecond && ( + {this.localizedDecimalSeparator} + )} + {this.showFractionalSecond && ( +
+ + + + + {this.localizedFractionalSecond || "--"} + + @@ -924,7 +1084,6 @@ export class TimePicker [CSS.buttonTopRight]: true, }} onClick={this.incrementMeridiem} - onKeyDown={this.meridiemUpButtonKeyDownHandler} role="button" > @@ -956,7 +1115,6 @@ export class TimePicker [CSS.buttonBottomRight]: true, }} onClick={this.decrementMeridiem} - onKeyDown={this.meridiemDownButtonKeyDownHandler} role="button" > diff --git a/packages/calcite-components/src/demos/input-time-picker.html b/packages/calcite-components/src/demos/input-time-picker.html index 5ab370d0b29..3d222be86c6 100644 --- a/packages/calcite-components/src/demos/input-time-picker.html +++ b/packages/calcite-components/src/demos/input-time-picker.html @@ -100,6 +100,25 @@

24-Hour Locales

} else { h23.append(labelEl); } + + labelEl = document.createElement("calcite-label"); + inputTimePickerEl = document.createElement("calcite-input-time-picker"); + + inputTimePickerEl.setAttribute("lang", locale); + if (numberingSystem) { + inputTimePickerEl.setAttribute("numbering-system", numberingSystem); + } + inputTimePickerEl.setAttribute("step", 0.001); + inputTimePickerEl.setAttribute("value", "10:00:00.001"); + labelEl.append(document.createTextNode(`${name} (${locale}) (milliseconds)`)); + labelEl.append(inputTimePickerEl); + mainEl.append(labelEl); + + if (localeObject.hourCycles[0] === "h12") { + h12.append(labelEl); + } else { + h23.append(labelEl); + } }); })(); diff --git a/packages/calcite-components/src/utils/math.spec.ts b/packages/calcite-components/src/utils/math.spec.ts index 2bbdd9feb28..c6c2ed96af4 100644 --- a/packages/calcite-components/src/utils/math.spec.ts +++ b/packages/calcite-components/src/utils/math.spec.ts @@ -13,6 +13,32 @@ describe("decimalPlaces", () => { expect(decimalPlaces(123)).toBe(0); expect(decimalPlaces(123.123)).toBe(3); }); + + it("returns the amount of non-zero decimal places for a given number string", () => { + expect(decimalPlaces("0")).toBe(0); + expect(decimalPlaces("0.0")).toBe(0); + expect(decimalPlaces("0.00")).toBe(0); + expect(decimalPlaces("0.000")).toBe(0); + expect(decimalPlaces("0.1")).toBe(1); + expect(decimalPlaces("0.01")).toBe(2); + expect(decimalPlaces("0.001")).toBe(3); + expect(decimalPlaces("0.0001")).toBe(4); + }); + + it("returns the amount of decimal places for a number representation of a decimal", () => { + expect(decimalPlaces(0)).toBe(0); + expect(decimalPlaces(0.0)).toBe(0); + expect(decimalPlaces(0.1)).toBe(1); + expect(decimalPlaces(0.01)).toBe(2); + expect(decimalPlaces(0.001)).toBe(3); + expect(decimalPlaces(0.0001)).toBe(4); + expect(decimalPlaces(1)).toBe(0); + expect(decimalPlaces(1.0)).toBe(0); + expect(decimalPlaces(1.1)).toBe(1); + expect(decimalPlaces(1.01)).toBe(2); + expect(decimalPlaces(1.001)).toBe(3); + expect(decimalPlaces(1.0001)).toBe(4); + }); }); describe("remap", () => { diff --git a/packages/calcite-components/src/utils/math.ts b/packages/calcite-components/src/utils/math.ts index 8698bb5da96..3555997cf96 100644 --- a/packages/calcite-components/src/utils/math.ts +++ b/packages/calcite-components/src/utils/math.ts @@ -2,9 +2,18 @@ export const clamp = (value: number, min: number, max: number): number => Math.m const decimalNumberRegex = new RegExp(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/); -export const decimalPlaces = (value: number): number => { +/** + * Returns the quantity of real decimal places for a number, which excludes trailing zeros. + * + * Adapted from {@link https://stackoverflow.com/questions/10454518/javascript-how-to-retrieve-the-number-of-decimals-of-a-string-number}. + * + * @param decimal - decimal value + * @param value + * @returns {number} the amount of decimal places in a number + */ +export const decimalPlaces = (value: number | string): number => { const match = ("" + value).match(decimalNumberRegex); - if (!match) { + if (!match || parseInt(match[1]) === 0) { return 0; } return Math.max( @@ -16,6 +25,13 @@ export const decimalPlaces = (value: number): number => { ); }; +export function getDecimals(value: number): number { + if (decimalPlaces(value) > 0 && value > 0) { + return parseFloat(`0.${value.toString().split(".")[1]}`); + } + return value; +} + export function remap(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number): number { return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; } diff --git a/packages/calcite-components/src/utils/time.spec.ts b/packages/calcite-components/src/utils/time.spec.ts index a7d01a4ac62..03acc940165 100644 --- a/packages/calcite-components/src/utils/time.spec.ts +++ b/packages/calcite-components/src/utils/time.spec.ts @@ -1,4 +1,198 @@ -import { toISOTimeString } from "./time"; +import { formatTimePart, isValidTime, localizeTimeStringToParts, parseTimeString, toISOTimeString } from "./time"; + +describe("formatTimePart", () => { + it("returns decimals less than 1 with leading and trailing zeros to match the provided length", () => { + expect(formatTimePart(0.3)).toEqual("3"); + expect(formatTimePart(0.3, 1)).toEqual("3"); + expect(formatTimePart(0.3, 2)).toEqual("30"); + expect(formatTimePart(0.3, 3)).toEqual("300"); + expect(formatTimePart(0.03)).toEqual("03"); + expect(formatTimePart(0.03, 2)).toEqual("03"); + expect(formatTimePart(0.03, 3)).toEqual("030"); + expect(formatTimePart(0.003)).toEqual("003"); + expect(formatTimePart(0.003, 3)).toEqual("003"); + }); + it("returns hour, minute and second values between 0 and 10 with leading zeros", () => { + expect(formatTimePart(0)).toEqual("00"); + expect(formatTimePart(1)).toEqual("01"); + expect(formatTimePart(2)).toEqual("02"); + expect(formatTimePart(3)).toEqual("03"); + expect(formatTimePart(4)).toEqual("04"); + expect(formatTimePart(5)).toEqual("05"); + expect(formatTimePart(6)).toEqual("06"); + expect(formatTimePart(7)).toEqual("07"); + expect(formatTimePart(8)).toEqual("08"); + expect(formatTimePart(9)).toEqual("09"); + }); +}); + +describe("isValidTime", () => { + it("returns true when time string contains fractional seconds", () => { + expect(isValidTime("12:30:45.0")).toBe(true); + expect(isValidTime("12:30:45.01")).toBe(true); + expect(isValidTime("12:30:45.001")).toBe(true); + expect(isValidTime("12:30:45.1")).toBe(true); + expect(isValidTime("12:30:45.12")).toBe(true); + expect(isValidTime("12:30:45.123")).toBe(true); + expect(isValidTime("12:30:45.1234")).toBe(true); + expect(isValidTime("12:30:45.12345")).toBe(true); + expect(isValidTime("12:30:45.123456")).toBe(true); + expect(isValidTime("12:30:45.1234567")).toBe(true); + expect(isValidTime("12:30:45.12345678")).toBe(true); + expect(isValidTime("12:30:45.123456789")).toBe(true); + }); +}); + +describe("localizeTimeStringToParts", () => { + it("returns localized decimal separator and fractional second value", () => { + expect(localizeTimeStringToParts({ value: "06:45:30.12123", locale: "fr" })).toEqual({ + localizedHour: "06", + localizedHourSuffix: ":", + localizedMinute: "45", + localizedMinuteSuffix: ":", + localizedSecond: "30", + localizedDecimalSeparator: ",", + localizedFractionalSecond: "12123", + localizedSecondSuffix: null, + localizedMeridiem: null, + }); + + expect(localizeTimeStringToParts({ value: "06:45:30", locale: "fr" })).toEqual({ + localizedHour: "06", + localizedHourSuffix: ":", + localizedMinute: "45", + localizedMinuteSuffix: ":", + localizedSecond: "30", + localizedDecimalSeparator: ",", + localizedFractionalSecond: null, + localizedSecondSuffix: null, + localizedMeridiem: null, + }); + + expect(localizeTimeStringToParts({ value: "06:45:30.12123", locale: "da" })).toEqual({ + localizedHour: "06", + localizedHourSuffix: ".", + localizedMinute: "45", + localizedMinuteSuffix: ".", + localizedSecond: "30", + localizedDecimalSeparator: ",", + localizedFractionalSecond: "12123", + localizedSecondSuffix: null, + localizedMeridiem: null, + }); + }); + + it("returns fractional second value with padded zeros when necessary", () => { + expect(localizeTimeStringToParts({ value: "06:45:30.04", locale: "en" })).toEqual({ + localizedHour: "06", + localizedHourSuffix: ":", + localizedMinute: "45", + localizedMinuteSuffix: ":", + localizedSecond: "30", + localizedDecimalSeparator: ".", + localizedFractionalSecond: "04", + localizedSecondSuffix: null, + localizedMeridiem: "AM", + }); + expect(localizeTimeStringToParts({ value: "06:45:30.003", locale: "en" })).toEqual({ + localizedHour: "06", + localizedHourSuffix: ":", + localizedMinute: "45", + localizedMinuteSuffix: ":", + localizedSecond: "30", + localizedDecimalSeparator: ".", + localizedFractionalSecond: "003", + localizedSecondSuffix: null, + localizedMeridiem: "AM", + }); + expect(localizeTimeStringToParts({ value: "06:45:30.007", locale: "ar", numberingSystem: "arab" })).toEqual({ + localizedHour: "٠٦", + localizedHourSuffix: ":", + localizedMinute: "٤٥", + localizedMinuteSuffix: ":", + localizedSecond: "٣٠", + localizedDecimalSeparator: "٫", + localizedFractionalSecond: "٠٠٧", + localizedSecondSuffix: null, + localizedMeridiem: "ص", + }); + }); +}); + +describe("parseTimeString", () => { + it("returns literal hour, minute, second and fractional second values from given string", () => { + expect(parseTimeString("12:30:45.0")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "0", + }); + expect(parseTimeString("12:30:45.01")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "01", + }); + expect(parseTimeString("12:30:45.001")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "001", + }); + expect(parseTimeString("12:30:45.0001")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "0001", + }); + expect(parseTimeString("12:30:45.0049")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "0049", + }); + expect(parseTimeString("12:30:45.1")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "1", + }); + expect(parseTimeString("12:30:45.12")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "12", + }); + expect(parseTimeString("12:30:45.123")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "123", + }); + expect(parseTimeString("12:30:45.1234")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "1234", + }); + expect(parseTimeString("12:30:45.12345")).toEqual({ + hour: "12", + minute: "30", + second: "45", + fractionalSecond: "12345", + }); + expect(parseTimeString("12:30:45.12345.34")).toEqual({ + hour: null, + minute: null, + second: null, + fractionalSecond: null, + }); + }); + + it("returns null fractionalSecond when second is a whole number", () => { + expect(parseTimeString("12:30:45")).toEqual({ fractionalSecond: null, hour: "12", minute: "30", second: "45" }); + }); +}); describe("toISOTimeString", () => { it("returns hh:mm value when includeSeconds is false", () => { diff --git a/packages/calcite-components/src/utils/time.ts b/packages/calcite-components/src/utils/time.ts index b87207c9f55..adf4c0416c3 100644 --- a/packages/calcite-components/src/utils/time.ts +++ b/packages/calcite-components/src/utils/time.ts @@ -1,5 +1,9 @@ -import { getDateTimeFormat, getSupportedNumberingSystem, NumberingSystem } from "./locale"; +import { getDateTimeFormat, getSupportedNumberingSystem, NumberingSystem, numberStringFormatter } from "./locale"; +import { decimalPlaces } from "./math"; import { isValidNumber } from "./number"; + +export type FractionalSecondDigits = 1 | 2 | 3; + export type HourCycle = "12" | "24"; export interface LocalizedTime { @@ -8,6 +12,8 @@ export interface LocalizedTime { localizedMinute: string; localizedMinuteSuffix: string; localizedSecond: string; + localizedDecimalSeparator: string; + localizedFractionalSecond: string; localizedSecondSuffix: string; localizedMeridiem: string; } @@ -17,19 +23,30 @@ export type Meridiem = "AM" | "PM"; export type MinuteOrSecond = "minute" | "second"; export interface Time { + fractionalSecond: string; hour: string; minute: string; second: string; } -export type TimePart = "hour" | "hourSuffix" | "minute" | "minuteSuffix" | "second" | "secondSuffix" | "meridiem"; +export type TimePart = + | "hour" + | "hourSuffix" + | "minute" + | "minuteSuffix" + | "second" + | "decimalSeparator" + | "fractionalSecond" + | "secondSuffix" + | "meridiem"; export const maxTenthForMinuteAndSecond = 5; function createLocaleDateTimeFormatter( locale: string, numberingSystem: NumberingSystem, - includeSeconds = true + includeSeconds = true, + fractionalSecondDigits?: FractionalSecondDigits ): Intl.DateTimeFormat { const options: Intl.DateTimeFormatOptions = { hour: "2-digit", @@ -39,28 +56,55 @@ function createLocaleDateTimeFormatter( }; if (includeSeconds) { options.second = "2-digit"; + if (fractionalSecondDigits) { + options.fractionalSecondDigits = fractionalSecondDigits; + } } return getDateTimeFormat(locale, options); } -export function formatTimePart(number: number): string { +export function formatTimePart(number: number, minLength?: number): string { + if (number === null || number === undefined) { + return; + } const numberAsString = number.toString(); - return number >= 0 && number <= 9 ? numberAsString.padStart(2, "0") : numberAsString; + const numberDecimalPlaces = decimalPlaces(number); + if (number < 1 && numberDecimalPlaces > 0 && numberDecimalPlaces < 4) { + const fractionalDigits = numberAsString.replace("0.", ""); + if (!minLength || fractionalDigits.length === minLength) { + return fractionalDigits; + } + if (fractionalDigits.length < minLength) { + return fractionalDigits.padEnd(minLength, "0"); + } + return fractionalDigits; + } + if (number >= 0 && number < 10) { + return numberAsString.padStart(2, "0"); + } + if (number >= 10) { + return numberAsString; + } } export function formatTimeString(value: string): string { if (!isValidTime(value)) { return null; } - const [hourString, minuteString, secondString] = value.split(":"); - const hour = formatTimePart(parseInt(hourString)); - const minute = formatTimePart(parseInt(minuteString)); - if (secondString) { - const second = formatTimePart(parseInt(secondString)); - return `${hour}:${minute}:${second}`; + const { hour, minute, second, fractionalSecond } = parseTimeString(value); + let formattedValue = `${formatTimePart(parseInt(hour))}:${formatTimePart(parseInt(minute))}`; + if (second) { + formattedValue += `:${formatTimePart(parseInt(second))}`; + if (fractionalSecond) { + formattedValue += `.${fractionalSecond}`; + } } - return `${hour}:${minute}`; + return formattedValue; +} + +function fractionalSecondPartToMilliseconds(fractionalSecondPart: string): number { + return parseInt((parseFloat(`0.${fractionalSecondPart}`) / 0.001).toFixed(3)); } export function getLocaleHourCycle(locale: string, numberingSystem: NumberingSystem): HourCycle { @@ -69,6 +113,24 @@ export function getLocaleHourCycle(locale: string, numberingSystem: NumberingSys return getLocalizedTimePart("meridiem", parts) ? "12" : "24"; } +export function getLocalizedDecimalSeparator(locale: string, numberingSystem: NumberingSystem): string { + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem, + }; + return numberStringFormatter.localize("1.1").split("")[1]; +} + +export function getLocalizedTimePartSuffix( + part: "hour" | "minute" | "second", + locale: string, + numberingSystem: NumberingSystem = "latn" +): string { + 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); +} + function getLocalizedTimePart(part: TimePart, parts: Intl.DateTimeFormatPart[]): string { if (!part || !parts) { return null; @@ -145,6 +207,29 @@ interface LocalizeTimePartParameters { } export function localizeTimePart({ value, part, locale, numberingSystem }: LocalizeTimePartParameters): string { + if (part === "fractionalSecond") { + const localizedDecimalSeparator = getLocalizedDecimalSeparator(locale, numberingSystem); + let localizedFractionalSecond = null; + if (value) { + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem, + }; + const localizedZero = numberStringFormatter.localize("0"); + if (parseInt(value) === 0) { + localizedFractionalSecond = "".padStart(value.length, localizedZero); + } else { + localizedFractionalSecond = numberStringFormatter + .localize(`0.${value}`) + .replace(`${localizedZero}${localizedDecimalSeparator}`, ""); + if (localizedFractionalSecond.length < value.length) { + localizedFractionalSecond = localizedFractionalSecond.padEnd(value.length, localizedZero); + } + } + } + return localizedFractionalSecond; + } + if (!isValidTimePart(value, part)) { return; } @@ -170,6 +255,7 @@ export function localizeTimePart({ value, part, locale, numberingSystem }: Local interface LocalizeTimeStringParameters { value: string; includeSeconds?: boolean; + fractionalSecondDigits?: FractionalSecondDigits; locale: string; numberingSystem: NumberingSystem; } @@ -179,32 +265,43 @@ export function localizeTimeString({ locale, numberingSystem, includeSeconds = true, + fractionalSecondDigits, }: LocalizeTimeStringParameters): string { 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))); - const formatter = createLocaleDateTimeFormatter(locale, numberingSystem, includeSeconds); - return formatter?.format(dateFromTimeString) || null; + const { hour, minute, second = "0", fractionalSecond } = parseTimeString(value); + + const dateFromTimeString = new Date( + Date.UTC( + 0, + 0, + 0, + parseInt(hour), + parseInt(minute), + parseInt(second), + fractionalSecond && fractionalSecondPartToMilliseconds(fractionalSecond) + ) + ); + const formatter = createLocaleDateTimeFormatter(locale, numberingSystem, includeSeconds, fractionalSecondDigits); + return formatter.format(dateFromTimeString) || null; } interface LocalizeTimeStringToPartsParameters { value: string; locale: string; - numberingSystem: NumberingSystem; + numberingSystem?: NumberingSystem; } export function localizeTimeStringToParts({ value, locale, - numberingSystem, + numberingSystem = "latn", }: LocalizeTimeStringToPartsParameters): LocalizedTime { if (!isValidTime(value)) { return null; } - - const { hour, minute, second = "0" } = parseTimeString(value); + 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); @@ -215,6 +312,13 @@ export function localizeTimeStringToParts({ localizedMinute: getLocalizedTimePart("minute", parts), localizedMinuteSuffix: getLocalizedTimePart("minuteSuffix", parts), localizedSecond: getLocalizedTimePart("second", parts), + localizedDecimalSeparator: getLocalizedDecimalSeparator(locale, numberingSystem), + localizedFractionalSecond: localizeTimePart({ + value: fractionalSecond, + part: "fractionalSecond", + locale, + numberingSystem, + }), localizedSecondSuffix: getLocalizedTimePart("secondSuffix", parts), localizedMeridiem: getLocalizedTimePart("meridiem", parts), }; @@ -243,14 +347,21 @@ export function getTimeParts({ value, locale, numberingSystem }: GetTimePartsPar export function parseTimeString(value: string): Time { if (isValidTime(value)) { - const [hour, minute, second] = value.split(":"); + const [hour, minute, secondDecimal] = value.split(":"); + let second = secondDecimal; + let fractionalSecond = null; + if (secondDecimal?.includes(".")) { + [second, fractionalSecond] = secondDecimal.split("."); + } return { + fractionalSecond, hour, minute, second, }; } return { + fractionalSecond: null, hour: null, minute: null, second: null, @@ -261,12 +372,15 @@ export function toISOTimeString(value: string, includeSeconds = true): string { if (!isValidTime(value)) { return ""; } - const { hour, minute, second } = parseTimeString(value); + const { hour, minute, second, fractionalSecond } = parseTimeString(value); let isoTimeString = `${formatTimePart(parseInt(hour))}:${formatTimePart(parseInt(minute))}`; if (includeSeconds) { isoTimeString += `:${formatTimePart(parseInt((includeSeconds && second) || "0"))}`; + if (fractionalSecond) { + isoTimeString += `.${fractionalSecond}`; + } } return isoTimeString;