diff --git a/.cspell.json b/.cspell.json index cfbdc7d7445f..351458e2874b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -3,7 +3,7 @@ "import": ["@taiga-ui/cspell-config/cspell.config.js"], "files": ["*/*.*"], "ignorePaths": ["**/projects/i18n/languages/**", "**/addon-commerce/utils/get-currency-symbol.ts"], - "ignoreWords": ["Wachovia", "bottomsheet", "appbar", "qwertypgj_", "antialiasing", "xxxs"], + "ignoreWords": ["Wachovia", "bottomsheet", "appbar", "qwertypgj_", "antialiasing", "xxxs", "significand"], "ignoreRegExpList": ["\\(https?://.*?\\)", "\\/{1}.+\\/{1}", "\\%2F.+", "\\%2C.+", "\\ɵ.+", "\\ыва.+"], "overrides": [ { diff --git a/projects/cdk/utils/math/round.ts b/projects/cdk/utils/math/round.ts index b3e70fb8b540..891400bcbd2e 100644 --- a/projects/cdk/utils/math/round.ts +++ b/projects/cdk/utils/math/round.ts @@ -23,9 +23,19 @@ function calculate( precision = Math.min(precision, MAX_PRECISION); - const pair = `${value}e`.split('e'); - const tempValue = func(Number(`${pair[0]}e${Number(pair[1]) + precision}`)); - const processedPair = `${tempValue}e`.split('e'); + const [significand, exponent = ''] = `${value}`.split('e'); + const roundedInt = func(Number(`${significand}e${Number(exponent) + precision}`)); + + /** + * TODO: use BigInt after bumping Safari to 14+ + */ + ngDevMode && + console.assert( + Number.isSafeInteger(roundedInt), + 'Impossible to correctly round such a large number', + ); + + const processedPair = `${roundedInt}e`.split('e'); return Number(`${processedPair[0]}e${Number(processedPair[1]) - precision}`); } @@ -45,3 +55,7 @@ export function tuiFloor(value: number, precision = 0): number { export function tuiTrunc(value: number, precision = 0): number { return calculate(value, precision, Math.trunc); } + +export function tuiIsSafeToRound(value: number, precision = 0): boolean { + return Number.isSafeInteger(Math.trunc(value * 10 ** precision)); +} diff --git a/projects/core/utils/format/test/format-number.spec.ts b/projects/core/utils/format/test/format-number.spec.ts index dff0d4dc6724..43c1921126c3 100644 --- a/projects/core/utils/format/test/format-number.spec.ts +++ b/projects/core/utils/format/test/format-number.spec.ts @@ -140,4 +140,14 @@ describe('Number formatting', () => { }), ).toBe('0'); }); + + it('does not mutate value if precision is infinite', () => { + expect( + tuiFormatNumber(123_456_789_012_345.67, { + precision: Infinity, + thousandSeparator: ',', + rounding: 'round', + }), + ).toBe('123,456,789,012,345.67'); + }); }); diff --git a/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts b/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts index ac487c4056d7..9193f7cce4ec 100644 --- a/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts +++ b/projects/demo-playwright/tests/legacy/input-number/input-number.pw.spec.ts @@ -29,6 +29,22 @@ test.describe('InputNumber', () => { await expect(example).toHaveScreenshot('01-input-number.png'); }); + test('does not mutate already valid too large number on blur', async ({page}) => { + await tuiGoto( + page, + `${DemoRoute.InputNumber}/API?thousandSeparator=_&precision=2`, + ); + await input.focus(); + await input.clear(); + await input.pressSequentially('123456789012345.6789'); + + await expect(input).toHaveValue('123_456_789_012_345.67'); + + await input.blur(); + + await expect(input).toHaveValue('123_456_789_012_345.67'); + }); + test('prefix + value + postfix', async ({page}) => { await tuiGoto( page, diff --git a/projects/legacy/components/input-number/input-number.component.ts b/projects/legacy/components/input-number/input-number.component.ts index 4eb1eefd2625..9886adff3412 100644 --- a/projects/legacy/components/input-number/input-number.component.ts +++ b/projects/legacy/components/input-number/input-number.component.ts @@ -19,7 +19,7 @@ import type {TuiValueTransformer} from '@taiga-ui/cdk/classes'; import {CHAR_HYPHEN, CHAR_MINUS, EMPTY_QUERY} from '@taiga-ui/cdk/constants'; import {tuiWatch} from '@taiga-ui/cdk/observables'; import {TUI_IS_IOS} from '@taiga-ui/cdk/tokens'; -import {tuiClamp} from '@taiga-ui/cdk/utils/math'; +import {tuiClamp, tuiIsSafeToRound} from '@taiga-ui/cdk/utils/math'; import {tuiCreateToken, tuiPure} from '@taiga-ui/cdk/utils/miscellaneous'; import type {TuiDecimalMode} from '@taiga-ui/core/tokens'; import {TUI_DEFAULT_NUMBER_FORMAT, TUI_NUMBER_FORMAT} from '@taiga-ui/core/tokens'; @@ -274,7 +274,15 @@ export class TuiInputNumberComponent this.computedPrefix + tuiFormatNumber(value, { ...this.numberFormat, - precision: this.precision, + /** + * Number can satisfy interval [Number.MIN_SAFE_INTEGER; Number.MAX_SAFE_INTEGER] + * but its rounding can violate it. + * Before BigInt support there is no perfect solution – only trade off. + * No rounding is better than lose precision and incorrect mutation of already valid value. + */ + precision: tuiIsSafeToRound(value, this.precision) + ? this.precision + : Infinity, }).replace(CHAR_HYPHEN, CHAR_MINUS) + this.computedPostfix );