From 746432c3cc534213cd15347a8bc98eee0bdbfdc5 Mon Sep 17 00:00:00 2001 From: dev-rb <43100342+dev-rb@users.noreply.github.com> Date: Sat, 24 Aug 2024 20:55:30 -0400 Subject: [PATCH] fix(number-field): precision handling with floating point offsets and value snapping (#468) --- .../src/number-field/number-field-root.tsx | 65 +++++++++++++++++-- packages/utils/src/number.ts | 11 ++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/core/src/number-field/number-field-root.tsx b/packages/core/src/number-field/number-field-root.tsx index de3b68b0..fcb5de71 100644 --- a/packages/core/src/number-field/number-field-root.tsx +++ b/packages/core/src/number-field/number-field-root.tsx @@ -2,8 +2,10 @@ import { type ValidationState, access, createGenerateId, + getPrecision, mergeDefaultProps, mergeRefs, + snapValueToStep, } from "@kobalte/utils"; import { type JSX, @@ -308,11 +310,33 @@ export function NumberFieldRoot( batch(() => { let newValue = rawValue; - if (rawValue % 1 === 0) { - newValue += offset; - } else { - if (offset > 0) newValue = Math.ceil(newValue); - else newValue = Math.floor(newValue); + const operation = offset > 0 ? "+" : "-"; + const localStep = Math.abs(offset); + // If there was no min or max provided, don't use our default values + // use NaN instead to help with the calculation which will use 0 + // instead for a NaN value + const min = + props.minValue === undefined ? Number.NaN : context.minValue(); + const max = + props.maxValue === undefined ? Number.NaN : context.maxValue(); + + // Try to snap the value to the nearest step + newValue = snapValueToStep(rawValue, min, max, localStep); + + // If the value didn't change in the direction we wanted to, + // then add the step and snap that value + if ( + !( + (operation === "+" && newValue > rawValue) || + (operation === "-" && newValue < rawValue) + ) + ) { + newValue = snapValueToStep( + handleDecimalOperation(operation, rawValue, localStep), + min, + max, + localStep, + ); } context.setValue(newValue); @@ -352,3 +376,34 @@ export function NumberFieldRoot( ); } + +function handleDecimalOperation( + operator: "-" | "+", + value1: number, + value2: number, +): number { + let result = operator === "+" ? value1 + value2 : value1 - value2; + if ( + Number.isFinite(value1) && + Number.isFinite(value2) && + (value2 % 1 !== 0 || value1 % 1 !== 0) + ) { + const offsetPrecision = getPrecision(value2); + const valuePrecision = getPrecision(value1); + + const multiplier = 10 ** Math.max(offsetPrecision, valuePrecision); + + const multipliedOffset = Math.round(value2 * multiplier); + const multipliedValue = Math.round(value1 * multiplier); + + const next = + operator === "+" + ? multipliedValue + multipliedOffset + : multipliedValue - multipliedOffset; + + // Undo multiplier to get the new value + result = next / multiplier; + } + + return result; +} diff --git a/packages/utils/src/number.ts b/packages/utils/src/number.ts index 9b777147..4646fd15 100644 --- a/packages/utils/src/number.ts +++ b/packages/utils/src/number.ts @@ -51,3 +51,14 @@ export function snapValueToStep( return snappedValue; } + +export const getPrecision = (n: number) => { + let e = 1; + let precision = 0; + while (Math.round(n * e) / e !== n) { + e *= 10; + precision++; + } + + return precision; +};