Skip to content

fix(input, input-number): increment/decrement unsafe numbers without loss of precision #6580

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Mar 14, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/components/input-number/input-number.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,55 @@ describe("calcite-input-number", () => {
page = await newE2EPage();
});

it("correctly increments/decrements numbers greater than MAX_SAFE_INTEGER", async () => {
await page.setContent(
html`<calcite-input-number
value="100000000000000000000000000000000000000000000000000."
step="10"
></calcite-input-number>`
);
const element = await page.find("calcite-input-number");
const numberHorizontalItemDown = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='down']"
);
const numberHorizontalItemUp = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='up']"
);
expect(await element.getProperty("value")).toBe("100000000000000000000000000000000000000000000000000");
await numberHorizontalItemUp.click();
await page.waitForChanges();
expect(await element.getProperty("value")).toBe("100000000000000000000000000000000000000000000000010");
element.setProperty("step", 0.1);
await page.waitForChanges();
Array.from({ length: 10 }, async () => await numberHorizontalItemDown.click());
await page.waitForChanges();
expect(await element.getProperty("value")).toBe("100000000000000000000000000000000000000000000000009");
});

it("correctly increments/decrements exponential notation numbers without losing precision", async () => {
await page.setContent(html`<calcite-input-number value="1.23e-60"></calcite-input-number>`);
const element = await page.find("calcite-input-number");
const numberHorizontalItemDown = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='down']"
);
const numberHorizontalItemUp = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='up']"
);
expect(await element.getProperty("value")).toBe("1.23e-60");
await numberHorizontalItemUp.click();
await page.waitForChanges();
expect(await element.getProperty("value")).toBe(
"1.00000000000000000000000000000000000000000000000000000000000123"
);
element.setProperty("step", 0.1);
await page.waitForChanges();
Array.from({ length: 5 }, async () => await numberHorizontalItemDown.click());
await page.waitForChanges();
expect(await element.getProperty("value")).toBe(
"0.50000000000000000000000000000000000000000000000000000000000123"
);
});

it("correctly increments and decrements decimal value when number buttons are clicked and the step precision matches the precision of the initial value", async () => {
await page.setContent(html`<calcite-input-number value="3.123" step="0.001"></calcite-input-number>`);

Expand Down
42 changes: 26 additions & 16 deletions src/components/input-number/input-number.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ import {
NumberingSystem,
numberStringFormatter
} from "../../utils/locale";
import { decimalPlaces } from "../../utils/math";
import { isValidNumber, parseNumberString, sanitizeNumberString } from "../../utils/number";
import {
BigDecimal,
isValidNumber,
parseNumberString,
sanitizeNumberString
} from "../../utils/number";
import { createObserver } from "../../utils/observers";
import { CSS_UTILITY } from "../../utils/resources";
import {
Expand Down Expand Up @@ -352,7 +356,7 @@ export class InputNumber
/** the computed icon to render */
private requestedIcon?: string;

private nudgeNumberValueIntervalId;
private nudgeNumberValueIntervalId: number;

mutationObserver = createObserver("mutation", () => this.setDisabledAction());

Expand Down Expand Up @@ -526,26 +530,32 @@ export class InputNumber
nativeEvent: KeyboardEvent | MouseEvent
): void {
const { value } = this;
const adjustment = direction === "up" ? 1 : -1;
const inputStep = this.step === "any" ? 1 : Math.abs(this.step || 1);
const inputVal = value && value !== "" ? parseFloat(value) : 0;
const inputVal = new BigDecimal(value !== "" ? value : "0");
const nudgedValue = inputVal.add(`${inputStep * adjustment}`);

const adjustment = direction === "up" ? 1 : -1;
const nudgedValue = inputVal + inputStep * adjustment;
const finalValue =
typeof inputMin === "number" && !isNaN(inputMin) && nudgedValue < inputMin
? inputMin
: typeof inputMax === "number" && !isNaN(inputMax) && nudgedValue > inputMax
? inputMax
: nudgedValue;
const nudgedValueBelowInputMin =
typeof inputMin === "number" &&
!isNaN(inputMin) &&
nudgedValue.subtract(`${inputMin}`).isNegative;

const nudgedValueAboveInputMax =
typeof inputMax === "number" &&
!isNaN(inputMax) &&
!nudgedValue.subtract(`${inputMax}`).isNegative;

const inputValPlaces = decimalPlaces(inputVal);
const inputStepPlaces = decimalPlaces(inputStep);
const finalValue = nudgedValueBelowInputMin
? `${inputMin}`
: nudgedValueAboveInputMax
? `${inputMax}`
: nudgedValue.toString();

this.setNumberValue({
committing: true,
nativeEvent,
origin: "user",
value: finalValue.toFixed(Math.max(inputValPlaces, inputStepPlaces))
value: finalValue
});
}

Expand Down Expand Up @@ -751,7 +761,7 @@ export class InputNumber
event.stopPropagation();
};

private setChildNumberElRef = (el) => {
private setChildNumberElRef = (el: HTMLInputElement) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨🏆✨

this.childNumberEl = el;
};

Expand Down
49 changes: 49 additions & 0 deletions src/components/input/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,55 @@ describe("calcite-input", () => {
page = await newE2EPage();
});

it("correctly increments/decrements numbers greater than MAX_SAFE_INTEGER", async () => {
await page.setContent(
html`<calcite-input-number
value="100000000000000000000000000000000000000000000000000."
step="10"
></calcite-input-number>`
);
const element = await page.find("calcite-input-number");
const numberHorizontalItemDown = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='down']"
);
const numberHorizontalItemUp = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='up']"
);
expect(await element.getProperty("value")).toBe("100000000000000000000000000000000000000000000000000");
await numberHorizontalItemUp.click();
await page.waitForChanges();
expect(await element.getProperty("value")).toBe("100000000000000000000000000000000000000000000000010");
element.setProperty("step", 0.1);
await page.waitForChanges();
Array.from({ length: 10 }, async () => await numberHorizontalItemDown.click());
await page.waitForChanges();
expect(await element.getProperty("value")).toBe("100000000000000000000000000000000000000000000000009");
});

it("correctly increments/decrements exponential notation numbers without losing precision", async () => {
await page.setContent(html`<calcite-input-number value="1.23e-60"></calcite-input-number>`);
const element = await page.find("calcite-input-number");
const numberHorizontalItemDown = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='down']"
);
const numberHorizontalItemUp = await page.find(
"calcite-input-number >>> .number-button-item[data-adjustment='up']"
);
expect(await element.getProperty("value")).toBe("1.23e-60");
await numberHorizontalItemUp.click();
await page.waitForChanges();
expect(await element.getProperty("value")).toBe(
"1.00000000000000000000000000000000000000000000000000000000000123"
);
element.setProperty("step", 0.1);
await page.waitForChanges();
Array.from({ length: 5 }, async () => await numberHorizontalItemDown.click());
await page.waitForChanges();
expect(await element.getProperty("value")).toBe(
"0.50000000000000000000000000000000000000000000000000000000000123"
);
});

it("correctly increments and decrements decimal value when number buttons are clicked and the step precision matches the precision of the initial value", async () => {
await page.setContent(html`<calcite-input type="number" value="3.123" step="0.001"></calcite-input>`);

Expand Down
39 changes: 25 additions & 14 deletions src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ import {
NumberingSystem,
numberStringFormatter
} from "../../utils/locale";
import { decimalPlaces } from "../../utils/math";
import { isValidNumber, parseNumberString, sanitizeNumberString } from "../../utils/number";

import {
BigDecimal,
isValidNumber,
parseNumberString,
sanitizeNumberString
} from "../../utils/number";
import { createObserver } from "../../utils/observers";
import { CSS_UTILITY } from "../../utils/resources";
import {
Expand Down Expand Up @@ -610,26 +615,32 @@ export class Input
nativeEvent: KeyboardEvent | MouseEvent
): void {
const { value } = this;
const adjustment = direction === "up" ? 1 : -1;
const inputStep = this.step === "any" ? 1 : Math.abs(this.step || 1);
const inputVal = value && value !== "" ? parseFloat(value) : 0;
const inputVal = new BigDecimal(value !== "" ? value : "0");
const nudgedValue = inputVal.add(`${inputStep * adjustment}`);

const adjustment = direction === "up" ? 1 : -1;
const nudgedValue = inputVal + inputStep * adjustment;
const finalValue =
typeof inputMin === "number" && !isNaN(inputMin) && nudgedValue < inputMin
? inputMin
: typeof inputMax === "number" && !isNaN(inputMax) && nudgedValue > inputMax
? inputMax
: nudgedValue;
const nudgedValueBelowInputMin =
typeof inputMin === "number" &&
!isNaN(inputMin) &&
nudgedValue.subtract(`${inputMin}`).isNegative;

const nudgedValueAboveInputMax =
typeof inputMax === "number" &&
!isNaN(inputMax) &&
!nudgedValue.subtract(`${inputMax}`).isNegative;

const inputValPlaces = decimalPlaces(inputVal);
const inputStepPlaces = decimalPlaces(inputStep);
const finalValue = nudgedValueBelowInputMin
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: can this be refactored to compute and evaluate a nudged value check at a time? It's currently computing two values where one might possibly be thrown away.

? `${inputMin}`
: nudgedValueAboveInputMax
? `${inputMax}`
: nudgedValue.toString();

this.setValue({
committing: true,
nativeEvent,
origin: "user",
value: finalValue.toFixed(Math.max(inputValPlaces, inputStepPlaces))
value: finalValue
});
}

Expand Down
8 changes: 4 additions & 4 deletions src/components/slider/slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,7 @@ export class Slider
return;
}
event.preventDefault();
const fixedDecimalAdjustment = Number(adjustment.toFixed(decimalPlaces(step)));
const fixedDecimalAdjustment = Number(adjustment.toFixed(decimalPlaces(`${step}`)));
this.setValue({
[activeProp as SetValueProperty]: this.clamp(fixedDecimalAdjustment, activeProp)
});
Expand Down Expand Up @@ -1200,7 +1200,7 @@ export class Slider
const percent = (x - left) / width;
const mirror = this.shouldMirror();
const clampedValue = this.clamp(this.min + range * (mirror ? 1 - percent : percent));
let value = Number(clampedValue.toFixed(decimalPlaces(this.step)));
let value = Number(clampedValue.toFixed(decimalPlaces(`${this.step}`)));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this fixes precision on the slider, can you update the PR title to reflect this? If it's to fix types, can you submit this in a separate PR? If it's the latter, we can merge the separate PR first and we can skip rolling it back here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter needs to be a string so the decimalPlaces function can also be used with BigDecimal. Previously step was being converted to a string in the decimalPlaces function itself, but we would lose precision if converting BigDecimal to number before passing it to the function. So it doesn't fix anything in slider, it's just a small, required refactor.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, Input isn't using decimalPlaces anymore, removing!

if (this.snap && this.step) {
value = this.getClosestStep(value);
}
Expand All @@ -1214,10 +1214,10 @@ export class Slider
* @internal
*/
private getClosestStep(num: number): number {
num = Number(this.clamp(num).toFixed(decimalPlaces(this.step)));
num = Number(this.clamp(num).toFixed(decimalPlaces(`${this.step}`)));
if (this.step) {
const step = Math.round(num / this.step) * this.step;
num = Number(this.clamp(step).toFixed(decimalPlaces(this.step)));
num = Number(this.clamp(step).toFixed(decimalPlaces(`${this.step}`)));
}
return num;
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/tooltip/tooltip.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ describe("calcite-tooltip", () => {
/**
* Helps assert the canceled Esc key press when closing tooltips
* Must be called before the tooltip is closed via keyboard.
*
* @param page
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you roll this file back? I think it was auto-fixed by the linter, but the file is unrelated to this PR.

*/
async function setUpEscapeKeyCancelListener(page: E2EPage): Promise<void> {
await page.evaluate(() => {
Expand Down
10 changes: 9 additions & 1 deletion src/utils/math.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { clamp } from "./math";
import { clamp, decimalPlaces } from "./math";

describe("clamp", () => {
it("clamps numbers within min/max", () => {
Expand All @@ -7,3 +7,11 @@ describe("clamp", () => {
expect(clamp(11, 0, 10)).toBe(10);
});
});

describe("decimalPlaces", () => {
it("returns largest number", () => {
expect(decimalPlaces("123.")).toBe(0);
expect(decimalPlaces("123.00000000000000000000000000000000000000000000000001")).toBe(50);
expect(decimalPlaces("123.123")).toBe(3);
});
});
6 changes: 4 additions & 2 deletions src/utils/math.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));

export const decimalPlaces = (value: number): number => {
const match = ("" + value).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
const decimalNumberRegex = new RegExp(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);

export const decimalPlaces = (value: string): number => {
const match = value.match(decimalNumberRegex);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: would test help simplify this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to use the values of the match a couple lines down

if (!match) {
return 0;
}
Expand Down
34 changes: 33 additions & 1 deletion src/utils/number.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { locales, numberStringFormatter } from "./locale";
import { BigDecimal, isValidNumber, parseNumberString, sanitizeNumberString } from "./number";
import {
BigDecimal,
expandExponentialNumberString,
isValidNumber,
parseNumberString,
sanitizeNumberString
} from "./number";

describe("isValidNumber", () => {
it("returns false for string values that can't compute to a number", () => {
Expand Down Expand Up @@ -137,3 +143,29 @@ describe("BigDecimal", () => {
});
});
});

describe("expandExponentialNumberString", () => {
it("integer based exponential numbers", () => {
expect(expandExponentialNumberString("123e4")).toBe("1230000");
expect(expandExponentialNumberString("1e50")).toBe("100000000000000000000000000000000000000000000000000");
});
it("decimal based exponential number strings", () => {
expect(expandExponentialNumberString(".987e0")).toBe("0.987");
expect(expandExponentialNumberString(".00000000000000000000000000000000000000000000000001e50")).toBe("1");
expect(expandExponentialNumberString("1.2345678987654321000000000000000000000000000000000000000000e16")).toBe(
"12345678987654321"
);
});
it("exponential number strings with negative magnitude", () => {
expect(expandExponentialNumberString("1.23e-60")).toBe(
"0.00000000000000000000000000000000000000000000000000000000000123"
);
expect(expandExponentialNumberString("100000000000000000000000000000000000000000000000000e-50")).toBe("1");
});
it("handles non-exponential numbers", () => {
expect(expandExponentialNumberString("1100000000000000000000000000000000000000000000000000")).toBe(
"1100000000000000000000000000000000000000000000000000"
);
expect(expandExponentialNumberString("")).toBe("");
});
});
Loading