diff --git a/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx b/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx
index 103f060..4fa68ad 100644
--- a/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx
+++ b/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx
@@ -51,6 +51,40 @@ describe(' abbreviated', () => {
expect(screen.getByRole('textbox')).toHaveValue('£1,599,000,000');
});
+ it('should handle 4.1m without floating-point precision issues', () => {
+ render();
+ userEvent.type(screen.getByRole('textbox'), '4.1m');
+
+ expect(onValueChangeSpy).toHaveBeenLastCalledWith('4100000', undefined, {
+ float: 4100000,
+ formatted: '£4,100,000',
+ value: '4100000',
+ });
+
+ expect(screen.getByRole('textbox')).toHaveValue('£4,100,000');
+ });
+
+ it('should handle other problematic decimal abbreviations', () => {
+ render();
+
+ userEvent.type(screen.getByRole('textbox'), '1.025m');
+ expect(onValueChangeSpy).toHaveBeenLastCalledWith('1025000', undefined, {
+ float: 1025000,
+ formatted: '$1,025,000',
+ value: '1025000',
+ });
+ expect(screen.getByRole('textbox')).toHaveValue('$1,025,000');
+
+ userEvent.clear(screen.getByRole('textbox'));
+ userEvent.type(screen.getByRole('textbox'), '2.1k');
+ expect(onValueChangeSpy).toHaveBeenLastCalledWith('2100', undefined, {
+ float: 2100,
+ formatted: '$2,100',
+ value: '2100',
+ });
+ expect(screen.getByRole('textbox')).toHaveValue('$2,100');
+ });
+
it('should not abbreviate any other letters', () => {
render();
userEvent.type(screen.getByRole('textbox'), '1.5e');
diff --git a/src/components/utils/__tests__/cleanValue.spec.ts b/src/components/utils/__tests__/cleanValue.spec.ts
index 067eaed..51281f8 100644
--- a/src/components/utils/__tests__/cleanValue.spec.ts
+++ b/src/components/utils/__tests__/cleanValue.spec.ts
@@ -267,6 +267,38 @@ describe('cleanValue', () => {
).toEqual('');
});
+ it('should handle floating-point precision issues in abbreviations', () => {
+ expect(
+ cleanValue({
+ value: '4.1m',
+ })
+ ).toEqual('4100000');
+
+ expect(
+ cleanValue({
+ value: '-4.11B',
+ })
+ ).toEqual('-4110000000');
+
+ expect(
+ cleanValue({
+ value: '1.025m',
+ })
+ ).toEqual('1025000');
+
+ expect(
+ cleanValue({
+ value: '2.1k',
+ })
+ ).toEqual('2100');
+
+ expect(
+ cleanValue({
+ value: '3.1m',
+ })
+ ).toEqual('3100000');
+ });
+
it('should ignore abbreviations if disableAbbreviations is true', () => {
expect(
cleanValue({
diff --git a/src/components/utils/__tests__/index.spec.ts b/src/components/utils/__tests__/index.spec.ts
new file mode 100644
index 0000000..8795847
--- /dev/null
+++ b/src/components/utils/__tests__/index.spec.ts
@@ -0,0 +1,105 @@
+import * as utils from '../index';
+
+describe('utils index exports', () => {
+ it('should export cleanValue function', () => {
+ expect(utils.cleanValue).toBeDefined();
+ expect(typeof utils.cleanValue).toBe('function');
+ });
+
+ it('should export fixedDecimalValue function', () => {
+ expect(utils.fixedDecimalValue).toBeDefined();
+ expect(typeof utils.fixedDecimalValue).toBe('function');
+ });
+
+ it('should export formatValue function', () => {
+ expect(utils.formatValue).toBeDefined();
+ expect(typeof utils.formatValue).toBe('function');
+ });
+
+ it('should export getLocaleConfig function', () => {
+ expect(utils.getLocaleConfig).toBeDefined();
+ expect(typeof utils.getLocaleConfig).toBe('function');
+ });
+
+ it('should export getSuffix function', () => {
+ expect(utils.getSuffix).toBeDefined();
+ expect(typeof utils.getSuffix).toBe('function');
+ });
+
+ it('should export isNumber function', () => {
+ expect(utils.isNumber).toBeDefined();
+ expect(typeof utils.isNumber).toBe('function');
+ });
+
+ it('should export padTrimValue function', () => {
+ expect(utils.padTrimValue).toBeDefined();
+ expect(typeof utils.padTrimValue).toBe('function');
+ });
+
+ it('should export repositionCursor function', () => {
+ expect(utils.repositionCursor).toBeDefined();
+ expect(typeof utils.repositionCursor).toBe('function');
+ });
+
+ it('should export safeMultiply function', () => {
+ expect(utils.safeMultiply).toBeDefined();
+ expect(typeof utils.safeMultiply).toBe('function');
+ });
+
+ describe('exported functions should work correctly', () => {
+ it('cleanValue should clean values', () => {
+ const result = utils.cleanValue({ value: '1,000' });
+ expect(result).toBe('1000');
+ });
+
+ it('fixedDecimalValue should fix decimals', () => {
+ const result = utils.fixedDecimalValue('123', '.', 2);
+ expect(result).toBe('1.23');
+ });
+
+ it('formatValue should format values', () => {
+ const result = utils.formatValue({ value: '1000' });
+ expect(result).toBe('1,000');
+ });
+
+ it('getLocaleConfig should return locale config', () => {
+ const result = utils.getLocaleConfig({ locale: 'en-US', currency: 'USD' });
+ expect(result).toHaveProperty('prefix');
+ expect(result).toHaveProperty('groupSeparator');
+ expect(result).toHaveProperty('decimalSeparator');
+ });
+
+ it('getSuffix should get suffix', () => {
+ const result = utils.getSuffix('100%', { groupSeparator: ',', decimalSeparator: '.' });
+ expect(result).toBe('%');
+ });
+
+ it('isNumber should validate numbers', () => {
+ expect(utils.isNumber('123')).toBe(true);
+ expect(utils.isNumber('abc')).toBe(false);
+ });
+
+ it('padTrimValue should pad/trim values', () => {
+ const result = utils.padTrimValue('1.5', '.', 2);
+ expect(result).toBe('1.50');
+ });
+
+ it('repositionCursor should calculate cursor position', () => {
+ const result = utils.repositionCursor({
+ selectionStart: 1,
+ value: '1000',
+ lastKeyStroke: '1',
+ stateValue: '',
+ groupSeparator: ',',
+ });
+ expect(result).toHaveProperty('modifiedValue');
+ expect(result).toHaveProperty('cursorPosition');
+ expect(typeof result.modifiedValue).toBe('string');
+ });
+
+ it('safeMultiply should multiply safely', () => {
+ const result = utils.safeMultiply(4.1, 1000000);
+ expect(result).toBe(4100000);
+ });
+ });
+});
diff --git a/src/components/utils/__tests__/parseAbbrValue.spec.ts b/src/components/utils/__tests__/parseAbbrValue.spec.ts
index 8d54b5e..be500dd 100644
--- a/src/components/utils/__tests__/parseAbbrValue.spec.ts
+++ b/src/components/utils/__tests__/parseAbbrValue.spec.ts
@@ -19,6 +19,20 @@ describe('abbrValue', () => {
expect(abbrValue(123456, '.')).toEqual('0.123456M');
expect(abbrValue(123456, '.', 2)).toEqual('0.12M');
});
+
+ describe('floating-point precision in abbreviation', () => {
+ it('should handle values with potential precision issues', () => {
+ expect(abbrValue(4100000)).toEqual('4.1M');
+ expect(abbrValue(1025000)).toEqual('1.025M');
+ expect(abbrValue(3100000)).toEqual('3.1M');
+ expect(abbrValue(2100000)).toEqual('2.1M');
+ });
+
+ it('should handle k abbreviations', () => {
+ expect(abbrValue(1500)).toEqual('1.5k');
+ expect(abbrValue(2100)).toEqual('2.1k');
+ });
+ });
});
describe('parseAbbrValue', () => {
@@ -77,4 +91,43 @@ describe('parseAbbrValue', () => {
expect(parseAbbrValue('1,2k', ',')).toEqual(1200);
expect(parseAbbrValue('2,3m', ',')).toEqual(2300000);
});
+
+ describe('floating-point precision fixes', () => {
+ it('should handle 4.1M without precision issues', () => {
+ expect(parseAbbrValue('4.1m')).toBe(4100000);
+ expect(parseAbbrValue('4.1M')).toBe(4100000);
+ });
+
+ it('should handle 1.025M without precision issues', () => {
+ expect(parseAbbrValue('1.025m')).toBe(1025000);
+ });
+
+ it('should handle 4.111M without precision issues', () => {
+ expect(parseAbbrValue('4.111m')).toBe(4111000);
+ });
+
+ it('should handle 3.1M without precision issues', () => {
+ expect(parseAbbrValue('3.1m')).toBe(3100000);
+ });
+
+ it('should handle 2.1M without precision issues', () => {
+ expect(parseAbbrValue('2.1m')).toBe(2100000);
+ });
+
+ it('should handle problematic decimal values with k', () => {
+ expect(parseAbbrValue('1.5k')).toBe(1500);
+ expect(parseAbbrValue('2.1k')).toBe(2100);
+ });
+
+ it('should handle problematic decimal values with b', () => {
+ expect(parseAbbrValue('1.1b')).toBe(1100000000);
+ expect(parseAbbrValue('2.5b')).toBe(2500000000);
+ });
+
+ it('should handle negative abbreviated values', () => {
+ expect(parseAbbrValue('-4.1m')).toBe(-4100000);
+ expect(parseAbbrValue('-1.5k')).toBe(-1500);
+ expect(parseAbbrValue('-2.5b')).toBe(-2500000000);
+ });
+ });
});
diff --git a/src/components/utils/__tests__/safeMultiply.spec.ts b/src/components/utils/__tests__/safeMultiply.spec.ts
new file mode 100644
index 0000000..3741ab8
--- /dev/null
+++ b/src/components/utils/__tests__/safeMultiply.spec.ts
@@ -0,0 +1,165 @@
+import { safeMultiply } from '../safeMultiply';
+
+describe('safeMultiply', () => {
+ describe('handles floating-point precision issues', () => {
+ it('should correctly multiply 4.1 * 1,000,000', () => {
+ expect(safeMultiply(4.1, 1_000_000)).toBe(4_100_000);
+ });
+
+ it('should correctly multiply 1.025 * 1,000,000', () => {
+ expect(safeMultiply(1.025, 1_000_000)).toBe(1_025_000);
+ });
+
+ it('should correctly multiply 4.111 * 1,000,000', () => {
+ expect(safeMultiply(4.111, 1_000_000)).toBe(4_111_000);
+ });
+
+ it('should correctly multiply 3.1 * 1,000,000', () => {
+ expect(safeMultiply(3.1, 1_000_000)).toBe(3_100_000);
+ });
+
+ it('should correctly multiply 2.1 * 1,000,000', () => {
+ expect(safeMultiply(2.1, 1_000_000)).toBe(2_100_000);
+ });
+
+ it('should handle zero values', () => {
+ expect(safeMultiply(0, 1_000_000)).toBe(0);
+ expect(safeMultiply(0, 1000)).toBe(0);
+ expect(safeMultiply(0, 1_000_000_000)).toBe(0);
+ });
+
+ it('should handle very small decimal values', () => {
+ expect(safeMultiply(0.0001233, 1_000_000)).toBe(123.3);
+ expect(safeMultiply(0.000456, 1_000_000)).toBe(456);
+ expect(safeMultiply(0.0000001, 1_000_000_000)).toBe(100);
+ });
+
+ it('should handle case demcimals with billions', () => {
+ expect(safeMultiply(0.1, 1_000_000_000)).toBe(100_000_000);
+ expect(safeMultiply(0.01, 1_000_000_000)).toBe(10_000_000);
+ expect(safeMultiply(0.001, 1_000_000_000)).toBe(1_000_000);
+ expect(safeMultiply(0.0001, 1_000_000_000)).toBe(100_000);
+ });
+
+ it('should handle case decimals with millions', () => {
+ expect(safeMultiply(0.1, 1_000_000)).toBe(100_000);
+ expect(safeMultiply(0.01, 1_000_000)).toBe(10_000);
+ expect(safeMultiply(0.001, 1_000_000)).toBe(1_000);
+ expect(safeMultiply(0.0001, 1_000_000)).toBe(100);
+ expect(safeMultiply(0.333333, 1_000_000)).toBe(333_333);
+ expect(safeMultiply(0.666666, 1_000_000)).toBe(666_666);
+ expect(safeMultiply(1.111111, 1_000_000)).toBe(1_111_111);
+ });
+
+ it('should handle case decimals with thousands', () => {
+ expect(safeMultiply(0.1, 1000)).toBe(100);
+ expect(safeMultiply(0.01, 1000)).toBe(10);
+ expect(safeMultiply(0.001, 1000)).toBe(1);
+ expect(safeMultiply(0.0001, 1000)).toBe(0.1);
+ });
+
+ it('should handle large numbers with many decimals', () => {
+ expect(safeMultiply(123.456789, 1_000_000)).toBe(123_456_789);
+ expect(safeMultiply(9.87654321, 1_000_000)).toBe(9_876_543.21);
+ expect(safeMultiply(0.123456789, 1_000_000_000)).toBe(123_456_789);
+ });
+ });
+
+ describe('handles abbreviation multipliers', () => {
+ it('should handle k (thousands)', () => {
+ expect(safeMultiply(1.5, 1000)).toBe(1500);
+ expect(safeMultiply(2.25, 1000)).toBe(2250);
+ });
+
+ it('should handle m (millions)', () => {
+ expect(safeMultiply(5.5, 1_000_000)).toBe(5_500_000);
+ expect(safeMultiply(10.75, 1_000_000)).toBe(10_750_000);
+ });
+
+ it('should handle b (billions)', () => {
+ expect(safeMultiply(1.1, 1_000_000_000)).toBe(1_100_000_000);
+ expect(safeMultiply(2.5, 1_000_000_000)).toBe(2_500_000_000);
+ });
+ });
+
+ describe('handles integer values', () => {
+ it('should multiply integers correctly', () => {
+ expect(safeMultiply(4, 1_000_000)).toBe(4_000_000);
+ expect(safeMultiply(10, 1000)).toBe(10_000);
+ });
+ });
+
+ describe('handles negative values', () => {
+ it('should handle negative decimals', () => {
+ expect(safeMultiply(-4.1, 1_000_000)).toBe(-4_100_000);
+ expect(safeMultiply(-1.025, 1_000_000)).toBe(-1_025_000);
+ });
+
+ it('should handle negative integers', () => {
+ expect(safeMultiply(-5, 1000)).toBe(-5000);
+ });
+ });
+
+ describe('handles edge cases', () => {
+ it('should handle zero', () => {
+ expect(safeMultiply(0, 1_000_000)).toBe(0);
+ expect(safeMultiply(0.0, 1000)).toBe(0);
+ });
+
+ it('should handle very small decimals', () => {
+ expect(safeMultiply(0.001, 1000)).toBe(1);
+ expect(safeMultiply(0.0001, 1_000_000)).toBe(100);
+ });
+
+ it('should handle many decimal places', () => {
+ expect(safeMultiply(1.123456, 1_000_000)).toBe(1_123_456);
+ expect(safeMultiply(0.999999, 1_000_000)).toBe(999_999);
+ });
+
+ it('should handle Infinity', () => {
+ expect(safeMultiply(Infinity, 1000)).toBe(NaN);
+ expect(safeMultiply(1.5, Infinity)).toBe(NaN);
+ });
+
+ it('should handle NaN', () => {
+ expect(safeMultiply(NaN, 1000)).toBe(NaN);
+ expect(safeMultiply(1.5, NaN)).toBe(NaN);
+ });
+ });
+
+ describe('maintains precision with various decimal lengths', () => {
+ it('should handle single decimal place', () => {
+ expect(safeMultiply(1.2, 1000)).toBe(1200);
+ expect(safeMultiply(9.9, 1000)).toBe(9900);
+ });
+
+ it('should handle two decimal places', () => {
+ expect(safeMultiply(1.25, 1000)).toBe(1250);
+ expect(safeMultiply(9.99, 1000)).toBe(9990);
+ });
+
+ it('should handle three decimal places', () => {
+ expect(safeMultiply(1.125, 1000)).toBe(1125);
+ expect(safeMultiply(9.999, 1000)).toBe(9999);
+ });
+ });
+
+ describe('handles non-power-of-10 multipliers', () => {
+ it('should handle multiplier that is not a power of 10', () => {
+ // These multipliers are not powers of 10, so they use regular multiplication
+ expect(safeMultiply(2, 7)).toBe(14);
+ expect(safeMultiply(3.5, 4)).toBe(14);
+ expect(safeMultiply(10, 25)).toBe(250);
+ });
+
+ it('should handle decimal multipliers', () => {
+ expect(safeMultiply(5, 1.5)).toBe(7.5);
+ expect(safeMultiply(2.5, 2.5)).toBe(6.25);
+ });
+
+ it('should handle non-integer multipliers', () => {
+ expect(safeMultiply(100, 0.5)).toBe(50);
+ expect(safeMultiply(7.5, 3.2)).toBe(24);
+ });
+ });
+});
diff --git a/src/components/utils/cleanValue.ts b/src/components/utils/cleanValue.ts
index 8c1fca9..baa699e 100644
--- a/src/components/utils/cleanValue.ts
+++ b/src/components/utils/cleanValue.ts
@@ -55,6 +55,7 @@ export const cleanValue = ({
]);
let valueOnly = withoutInvalidChars;
+ let parsedAbbreviation = false;
if (!disableAbbreviations) {
// disallow letter without number
@@ -66,13 +67,19 @@ export const cleanValue = ({
return '';
}
const parsed = parseAbbrValue(withoutInvalidChars, decimalSeparator);
- if (parsed) {
+ if (parsed !== undefined) {
valueOnly = String(parsed);
+ parsedAbbreviation = true;
}
}
const includeNegative = isNegative && allowNegativeValue ? '-' : '';
+ // If we parsed an abbreviation, return the expanded value directly
+ if (parsedAbbreviation) {
+ return `${includeNegative}${valueOnly}`;
+ }
+
if (decimalSeparator && valueOnly.includes(decimalSeparator)) {
const [int, decimals] = withoutInvalidChars.split(decimalSeparator);
const trimmedDecimals = decimalsLimit && decimals ? decimals.slice(0, decimalsLimit) : decimals;
diff --git a/src/components/utils/index.ts b/src/components/utils/index.ts
index acfa98c..64980ce 100644
--- a/src/components/utils/index.ts
+++ b/src/components/utils/index.ts
@@ -8,3 +8,4 @@ export { getSuffix } from './getSuffix';
export { isNumber } from './isNumber';
export { padTrimValue } from './padTrimValue';
export { repositionCursor } from './repositionCursor';
+export { safeMultiply } from './safeMultiply';
diff --git a/src/components/utils/parseAbbrValue.ts b/src/components/utils/parseAbbrValue.ts
index 61a2ea5..e726431 100644
--- a/src/components/utils/parseAbbrValue.ts
+++ b/src/components/utils/parseAbbrValue.ts
@@ -1,4 +1,5 @@
import { escapeRegExp } from './escapeRegExp';
+import { safeMultiply } from './safeMultiply';
/**
* Abbreviate number eg. 1000 = 1k
@@ -12,7 +13,8 @@ export const abbrValue = (value: number, decimalSeparator = '.', _decimalPlaces
const d = p(10, _decimalPlaces);
valueLength -= valueLength % 3;
- const abbrValue = Math.round((value * d) / p(10, valueLength)) / d + ' kMGTPE'[valueLength / 3];
+ const scaledValue = safeMultiply(value, d);
+ const abbrValue = Math.round(scaledValue / p(10, valueLength)) / d + ' kMGTPE'[valueLength / 3];
return abbrValue.replace('.', decimalSeparator);
}
@@ -27,14 +29,15 @@ const abbrMap: AbbrMap = { k: 1000, m: 1000000, b: 1000000000 };
* Parse a value with abbreviation e.g 1k = 1000
*/
export const parseAbbrValue = (value: string, decimalSeparator = '.'): number | undefined => {
- const reg = new RegExp(`(\\d+(${escapeRegExp(decimalSeparator)}\\d*)?)([kmb])$`, 'i');
+ const reg = new RegExp(`(-?\\d+(?:${escapeRegExp(decimalSeparator)}\\d*)?)([kmb])$`, 'i');
const match = value.match(reg);
if (match) {
- const [, digits, , abbr] = match;
+ const [, digits, abbr] = match;
const multiplier = abbrMap[abbr.toLowerCase()];
+ const numericValue = Number(digits.replace(decimalSeparator, '.'));
- return Number(digits.replace(decimalSeparator, '.')) * multiplier;
+ return safeMultiply(numericValue, multiplier);
}
return undefined;
diff --git a/src/components/utils/safeMultiply.ts b/src/components/utils/safeMultiply.ts
new file mode 100644
index 0000000..bb5cd83
--- /dev/null
+++ b/src/components/utils/safeMultiply.ts
@@ -0,0 +1,40 @@
+/**
+ * Safely multiply a decimal number by an integer multiplier to avoid
+ * floating-point precision issues.
+ *
+ * Uses scientific notation (e.g., 4.1e6) which avoids the precision issues
+ * that occur with direct multiplication (e.g., 4.1 * 1000000 = 4099999.9999999995)
+ *
+ * @param value - The decimal number to multiply (e.g., 4.1)
+ * @param multiplier - The integer multiplier (e.g., 1000000)
+ * @returns The result as a number
+ *
+ * @example
+ * safeMultiply(4.1, 1_000_000) // Returns 4100000 (using 4.1e6)
+ * safeMultiply(1.025, 1_000_000) // Returns 1025000 (using 1.025e6)
+ * safeMultiply(2.1, 1_000) // Returns 2100 (using 2.1e3)
+ * safeMultiply(-4.1, 1_000_000) // Returns -4100000 (using -4.1e6)
+ */
+export const safeMultiply = (value: number, multiplier: number): number => {
+ if (!isFinite(value) || !isFinite(multiplier)) {
+ return NaN;
+ }
+
+ // Calculate the exponent (number of zeros in the multiplier)
+ // e.g., 1000 -> 3, 1000000 -> 6, 1000000000 -> 9
+ const exponent = Math.log10(multiplier);
+
+ // If multiplier is a power of 10 (e.g., 1000, 1000000), use scientific notation
+ // This avoids floating-point precision issues
+ if (Number.isInteger(exponent)) {
+ // Convert to exponential notation, extract mantissa and current exponent
+ // then add the multiplier's exponent
+ const expString = value.toExponential();
+ const [mantissa, currentExp] = expString.split('e');
+ const newExp = parseInt(currentExp) + exponent;
+ return parseFloat(mantissa + 'e' + newExp);
+ }
+
+ // Fallback to regular multiplication for non-power-of-10 multipliers
+ return value * multiplier;
+};
diff --git a/src/index.ts b/src/index.ts
index bb25a17..40bc9a3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,5 +9,6 @@ export type {
export { CurrencyInput } from './components/CurrencyInput';
export { formatValue } from './components/utils/formatValue';
export { cleanValue } from './components/utils/cleanValue';
+export { safeMultiply } from './components/utils/safeMultiply';
export default CurrencyInput;