diff --git a/packages/calcite-components/src/components/input-number/input-number.e2e.ts b/packages/calcite-components/src/components/input-number/input-number.e2e.ts index b098fb6b80b..834372447af 100644 --- a/packages/calcite-components/src/components/input-number/input-number.e2e.ts +++ b/packages/calcite-components/src/components/input-number/input-number.e2e.ts @@ -899,6 +899,35 @@ describe("calcite-input-number", () => { expect(Number(await element.getProperty("value"))).toBe(195); }); + it("allows deleting exponentail number from decimal and adding trailing zeros", async () => { + const page = await newE2EPage(); + await page.setContent(html``); + + const calciteInput = await page.find("calcite-input-number"); + const input = await page.find("calcite-input-number >>> input"); + await calciteInput.callMethod("setFocus"); + await page.waitForChanges(); + await typeNumberValue(page, "2.100e10"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1e10"); + expect(await input.getProperty("value")).toBe("2.1e10"); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1e1"); + expect(await input.getProperty("value")).toBe("2.1e1"); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1"); + expect(await input.getProperty("value")).toBe("2.1"); + + await page.keyboard.type("000"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1000"); + expect(await input.getProperty("value")).toBe("2.1000"); + }); + it("disallows typing non-numeric characters with shift modifier key down", async () => { const page = await newE2EPage(); await page.setContent(html``); @@ -1114,6 +1143,80 @@ describe("calcite-input-number", () => { expect(await calciteInput.getProperty("value")).toBe(assertedValue); expect(await internalLocaleInput.getProperty("value")).toBe(numberStringFormatter.localize(assertedValue)); }); + + it(`should be able to append values after Backspace for ${locale} locale`, async () => { + const page = await newE2EPage(); + await page.setContent(` + + `); + + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem: "latn", + useGrouping: false + }; + const decimalSeparator = numberStringFormatter.decimal; + const calciteInput = await page.find("calcite-input-number"); + const input = await page.find("calcite-input-number >>> input"); + await calciteInput.callMethod("setFocus"); + await typeNumberValue(page, `0${decimalSeparator}0000`); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}0000`); + + await page.keyboard.press("Backspace"); + await typeNumberValue(page, "1"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}0001`); + + await typeNumberValue(page, "01"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}000101`); + }); + + it(`should keep leading decimal separator while input is focused on Backspace ${locale} locale `, async () => { + const page = await newE2EPage(); + await page.setContent(` + + `); + + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem: "latn", + useGrouping: false + }; + const decimalSeparator = numberStringFormatter.decimal; + const calciteInput = await page.find("calcite-input-number"); + const input = await page.find("calcite-input-number >>> input"); + await calciteInput.callMethod("setFocus"); + await typeNumberValue(page, `0${decimalSeparator}01`); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}01`); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}0`); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}`); + + await typeNumberValue(page, "01"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}01`); + }); + + it(`should sanitize leading decimal zeros on initial render ${locale} locale`, async () => { + const page = await newE2EPage(); + await page.setContent(html``); + + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem: "latn", + useGrouping: false + }; + const input = await page.find("calcite-input-number >>> input"); + expect(await input.getProperty("value")).toBe("0"); + }); }); }); @@ -1373,7 +1476,7 @@ describe("calcite-input-number", () => { await page.keyboard.press("Backspace"); await page.waitForChanges(); - expect(await element.getProperty("value")).toBe("1"); + expect(await element.getProperty("value")).toBe("1."); expect(calciteInputNumberInput).toHaveReceivedEventTimes(1); }); diff --git a/packages/calcite-components/src/components/input-number/input-number.tsx b/packages/calcite-components/src/components/input-number/input-number.tsx index 9893db14c39..356b325c3c4 100644 --- a/packages/calcite-components/src/components/input-number/input-number.tsx +++ b/packages/calcite-components/src/components/input-number/input-number.tsx @@ -42,13 +42,13 @@ import { } from "../../utils/loadable"; import { connectLocalized, - defaultNumberingSystem, disconnectLocalized, LocalizedComponent, NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { + addLocalizedTrailingDecimalZeros, BigDecimal, isValidNumber, parseNumberString, @@ -840,12 +840,11 @@ export class InputNumber useGrouping: this.groupSeparator }; - const sanitizedValue = sanitizeNumberString( - // no need to delocalize a string that ia already in latn numerals - (this.numberingSystem && this.numberingSystem !== "latn") || defaultNumberingSystem !== "latn" - ? numberStringFormatter.delocalize(value) - : value - ); + const isValueDeleted = + this.previousValue?.length > value.length || this.value?.length > value.length; + const hasTrailingDecimalSeparator = value.charAt(value.length - 1) === "."; + const sanitizedValue = + hasTrailingDecimalSeparator && isValueDeleted ? value : sanitizeNumberString(value); const newValue = value && !sanitizedValue @@ -854,8 +853,21 @@ export class InputNumber : "" : sanitizedValue; - const newLocalizedValue = numberStringFormatter.localize(newValue); - this.localizedValue = newLocalizedValue; + let newLocalizedValue = numberStringFormatter.localize(newValue); + + if (origin !== "connected" && !hasTrailingDecimalSeparator) { + newLocalizedValue = addLocalizedTrailingDecimalZeros( + newLocalizedValue, + newValue, + numberStringFormatter + ); + } + + // adds localized trailing decimal separator + this.localizedValue = + hasTrailingDecimalSeparator && isValueDeleted + ? `${newLocalizedValue}${numberStringFormatter.decimal}` + : newLocalizedValue; this.setPreviousNumberValue(previousValue ?? this.value); this.previousValueOrigin = origin; diff --git a/packages/calcite-components/src/components/input/input.e2e.ts b/packages/calcite-components/src/components/input/input.e2e.ts index b87f8c4e805..7ca4e0a3302 100644 --- a/packages/calcite-components/src/components/input/input.e2e.ts +++ b/packages/calcite-components/src/components/input/input.e2e.ts @@ -1055,6 +1055,35 @@ describe("calcite-input", () => { expect(Number(await element.getProperty("value"))).toBe(195); }); + it("allows deleting exponentail number from decimal and adding trailing zeros", async () => { + const page = await newE2EPage(); + await page.setContent(html``); + + const calciteInput = await page.find("calcite-input"); + const input = await page.find("calcite-input >>> input"); + await calciteInput.callMethod("setFocus"); + await page.waitForChanges(); + await typeNumberValue(page, "2.100e10"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1e10"); + expect(await input.getProperty("value")).toBe("2.1e10"); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1e1"); + expect(await input.getProperty("value")).toBe("2.1e1"); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1"); + expect(await input.getProperty("value")).toBe("2.1"); + + await page.keyboard.type("000"); + await page.waitForChanges(); + expect(await calciteInput.getProperty("value")).toBe("2.1000"); + expect(await input.getProperty("value")).toBe("2.1000"); + }); + it("disallows typing any non-numeric characters with shift modifier key down", async () => { const page = await newE2EPage(); await page.setContent(html``); @@ -1291,6 +1320,80 @@ describe("calcite-input", () => { expect(await calciteInput.getProperty("value")).toBe(assertedValue); expect(await internalLocaleInput.getProperty("value")).toBe(localizedValue); }); + + it(`should be able to append values after Backspace for ${locale} locale`, async () => { + const page = await newE2EPage(); + await page.setContent(` + + `); + + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem: "latn", + useGrouping: false + }; + const decimalSeparator = numberStringFormatter.decimal; + const calciteInput = await page.find("calcite-input"); + const input = await page.find("calcite-input >>> input"); + await calciteInput.callMethod("setFocus"); + await typeNumberValue(page, `0${decimalSeparator}0000`); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}0000`); + + await page.keyboard.press("Backspace"); + await typeNumberValue(page, "1"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}0001`); + + await typeNumberValue(page, "01"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}000101`); + }); + + it(`should keep leading decimal separator while input is focused on Backspace ${locale} locale `, async () => { + const page = await newE2EPage(); + await page.setContent(` + + `); + + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem: "latn", + useGrouping: false + }; + const decimalSeparator = numberStringFormatter.decimal; + const calciteInput = await page.find("calcite-input"); + const input = await page.find("calcite-input >>> input"); + await calciteInput.callMethod("setFocus"); + await typeNumberValue(page, `0${decimalSeparator}01`); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}01`); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}0`); + + await page.keyboard.press("Backspace"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}`); + + await typeNumberValue(page, "01"); + await page.waitForChanges(); + expect(await input.getProperty("value")).toBe(`0${decimalSeparator}01`); + }); + + it(`should sanitize leading decimal zeros on initial render ${locale} locale`, async () => { + const page = await newE2EPage(); + await page.setContent(html``); + + numberStringFormatter.numberFormatOptions = { + locale, + numberingSystem: "latn", + useGrouping: false + }; + const input = await page.find("calcite-input >>> input"); + expect(await input.getProperty("value")).toBe("0"); + }); }); }); @@ -1551,7 +1654,7 @@ describe("calcite-input", () => { await page.keyboard.press("Backspace"); await page.waitForChanges(); - expect(await element.getProperty("value")).toBe("1"); + expect(await element.getProperty("value")).toBe("1."); expect(calciteInputInput).toHaveReceivedEventTimes(1); }); diff --git a/packages/calcite-components/src/components/input/input.tsx b/packages/calcite-components/src/components/input/input.tsx index c89165e1c19..3e63f670163 100644 --- a/packages/calcite-components/src/components/input/input.tsx +++ b/packages/calcite-components/src/components/input/input.tsx @@ -42,7 +42,6 @@ import { } from "../../utils/loadable"; import { connectLocalized, - defaultNumberingSystem, disconnectLocalized, LocalizedComponent, NumberingSystem, @@ -50,6 +49,7 @@ import { } from "../../utils/locale"; import { + addLocalizedTrailingDecimalZeros, BigDecimal, isValidNumber, parseNumberString, @@ -975,13 +975,11 @@ export class Input signDisplay: "never" }; - const sanitizedValue = sanitizeNumberString( - // no need to delocalize a string that ia already in latn numerals - (this.numberingSystem && this.numberingSystem !== "latn") || - defaultNumberingSystem !== "latn" - ? numberStringFormatter.delocalize(value) - : value - ); + const isValueDeleted = + this.previousValue?.length > value.length || this.value?.length > value.length; + const hasTrailingDecimalSeparator = value.charAt(value.length - 1) === "."; + const sanitizedValue = + hasTrailingDecimalSeparator && isValueDeleted ? value : sanitizeNumberString(value); const newValue = value && !sanitizedValue @@ -990,8 +988,21 @@ export class Input : "" : sanitizedValue; - const newLocalizedValue = numberStringFormatter.localize(newValue); - this.localizedValue = newLocalizedValue; + let newLocalizedValue = numberStringFormatter.localize(newValue); + + if (origin !== "connected" && !hasTrailingDecimalSeparator) { + newLocalizedValue = addLocalizedTrailingDecimalZeros( + newLocalizedValue, + newValue, + numberStringFormatter + ); + } + + // adds localized trailing decimal separator + this.localizedValue = + hasTrailingDecimalSeparator && isValueDeleted + ? `${newLocalizedValue}${numberStringFormatter.decimal}` + : newLocalizedValue; this.userChangedValue = origin === "user" && this.value !== newValue; // don't sanitize the start of negative/decimal numbers, but diff --git a/packages/calcite-components/src/utils/number.spec.ts b/packages/calcite-components/src/utils/number.spec.ts index 85d10c9cdaa..bfd82119f6b 100644 --- a/packages/calcite-components/src/utils/number.spec.ts +++ b/packages/calcite-components/src/utils/number.spec.ts @@ -1,6 +1,7 @@ import { locales, numberStringFormatter } from "./locale"; import { BigDecimal, + addLocalizedTrailingDecimalZeros, expandExponentialNumberString, isValidNumber, parseNumberString, @@ -82,6 +83,7 @@ describe("sanitizeNumberString", () => { const nonLeadingZeroExponentialString = "500000e00600"; const multiDecimalExponentialString = "1.2e2.1"; const crazyExponentialString = "-2-.-1ee.5-3e.1..e--09"; + const trailingDecimalZeros = "0.110000"; expect(sanitizeNumberString(stringWithMultipleDashes)).toBe("1234"); expect(sanitizeNumberString(negativeStringWithMultipleDashes)).toBe("-1234"); @@ -102,6 +104,7 @@ describe("sanitizeNumberString", () => { expect(sanitizeNumberString(nonLeadingZeroExponentialString)).toBe("500000e600"); expect(sanitizeNumberString(multiDecimalExponentialString)).toBe("1.2e21"); expect(sanitizeNumberString(crazyExponentialString)).toBe("-2.1e53109"); + expect(sanitizeNumberString(trailingDecimalZeros)).toBe("0.110000"); }); }); @@ -169,3 +172,60 @@ describe("expandExponentialNumberString", () => { expect(expandExponentialNumberString("")).toBe(""); }); }); + +describe("addLocalizedTrailingDecimalZeros", () => { + function getLocalizedDeimalValue(value: string, trailingZeros: number): String { + const localizedValue = numberStringFormatter.localize(value); + const localizedZeroValue = numberStringFormatter.localize("0"); + return `${localizedValue}`.padEnd(localizedValue.length + trailingZeros, localizedZeroValue); + } + + locales.forEach((locale) => { + it(`add back sanitized trailing decimal zero values - ${locale}`, () => { + numberStringFormatter.numberFormatOptions = { + locale, + // the group separator is different in arabic depending on the numberingSystem + numberingSystem: locale === "ar" ? "arab" : "latn", + useGrouping: true + }; + + const stringWithTrailingZeros = "123456.1000"; + const bigDecimalWithTrailingZeros = + "1230000000000000000000000000000.00000000000000000000045000000000000000000000000"; + const negativeExponentialString = "-10.021e10000"; + + expect( + addLocalizedTrailingDecimalZeros( + numberStringFormatter.localize(stringWithTrailingZeros), + stringWithTrailingZeros, + numberStringFormatter + ) + ).toBe(getLocalizedDeimalValue(stringWithTrailingZeros, 3)); + expect( + addLocalizedTrailingDecimalZeros( + numberStringFormatter.localize(bigDecimalWithTrailingZeros), + bigDecimalWithTrailingZeros, + numberStringFormatter + ) + ).toBe(getLocalizedDeimalValue(bigDecimalWithTrailingZeros, 24)); + expect( + addLocalizedTrailingDecimalZeros( + numberStringFormatter.localize(negativeExponentialString), + negativeExponentialString, + numberStringFormatter + ) + ).toBe(numberStringFormatter.localize(negativeExponentialString)); + }); + + it(`returns same value if no trailing decimal zero value is removed - ${locale}`, () => { + numberStringFormatter.numberFormatOptions = { + locale, + // the group separator is different in arabic depending on the numberingSystem + numberingSystem: locale === "ar" ? "arab" : "latn", + useGrouping: true + }; + const localizedValue = numberStringFormatter.localize("0.001"); + expect(addLocalizedTrailingDecimalZeros(localizedValue, "0.001", numberStringFormatter)).toBe(localizedValue); + }); + }); +}); diff --git a/packages/calcite-components/src/utils/number.ts b/packages/calcite-components/src/utils/number.ts index 7628b0fd408..e4ab0a4d6d5 100644 --- a/packages/calcite-components/src/utils/number.ts +++ b/packages/calcite-components/src/utils/number.ts @@ -130,6 +130,7 @@ const allLeadingZerosOptionallyNegative = /^([-0])0+(?=\d)/; const decimalOnlyAtEndOfString = /(?!^\.)\.$/; const allHyphensExceptTheStart = /(?!^-)-/g; const isNegativeDecimalOnlyZeros = /^-\b0\b\.?0*$/; +const hasTrailingDecimalZeros = /0*$/; export const sanitizeNumberString = (numberString: string): string => sanitizeExponentialNumberString(numberString, (nonExpoNumString) => { @@ -137,14 +138,23 @@ export const sanitizeNumberString = (numberString: string): string => .replace(allHyphensExceptTheStart, "") .replace(decimalOnlyAtEndOfString, "") .replace(allLeadingZerosOptionallyNegative, "$1"); - return isValidNumber(sanitizedValue) ? isNegativeDecimalOnlyZeros.test(sanitizedValue) ? sanitizedValue - : new BigDecimal(sanitizedValue).toString() + : getBigDecimalAsString(sanitizedValue) : nonExpoNumString; }); +export function getBigDecimalAsString(sanitizedValue: string): string { + const sanitizedValueDecimals = sanitizedValue.split(".")[1]; + const value = new BigDecimal(sanitizedValue).toString(); + const [bigDecimalValueInteger, bigDecimalValueDecimals] = value.split("."); + + return sanitizedValueDecimals && bigDecimalValueDecimals !== sanitizedValueDecimals + ? `${bigDecimalValueInteger}.${sanitizedValueDecimals}` + : value; +} + export function sanitizeExponentialNumberString(numberString: string, func: (s: string) => string): string { if (!numberString) { return numberString; @@ -217,3 +227,35 @@ export function expandExponentialNumberString(numberString: string): string { function stringContainsNumbers(string: string): boolean { return numberKeys.some((number) => string.includes(number)); } + +/** + * Adds localized trailing decimals zero values to the number string. + * BigInt conversion to string removes the trailing decimal zero values (Ex: 1.000 is returned as 1). This method helps adding them back. + * + * @param {string} localizedValue - localized number string value + * @param {string} value - current value in the input field + * @param {NumberStringFormat} numberStringFormatter - numberStringFormatter instance to localize the number value + * @returns {string} localized number string value + */ +export function addLocalizedTrailingDecimalZeros( + localizedValue: string, + value: string, + formatter: NumberStringFormat +): string { + const decimals = value.split(".")[1]; + if (decimals) { + const trailingDecimalZeros = decimals.match(hasTrailingDecimalZeros)[0]; + if ( + trailingDecimalZeros && + formatter.delocalize(localizedValue).length !== value.length && + decimals.indexOf("e") === -1 + ) { + const decimalSeparator = formatter.decimal; + localizedValue = !localizedValue.includes(decimalSeparator) + ? `${localizedValue}${decimalSeparator}` + : localizedValue; + return localizedValue.padEnd(localizedValue.length + trailingDecimalZeros.length, formatter.localize("0")); + } + } + return localizedValue; +}