{
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;