@base-ui/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
108 lines (102 loc) • 5.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.hasNumberFormatRoundingOptions = hasNumberFormatRoundingOptions;
exports.removeFloatingPointErrors = removeFloatingPointErrors;
exports.toValidatedNumber = toValidatedNumber;
var _clamp = require("../../internals/clamp");
var _formatNumber = require("../../utils/formatNumber");
var _parse = require("./parse");
// A relative factor scaled by the step size when snapping (`stepSize * STEP_EPSILON_FACTOR`).
const STEP_EPSILON_FACTOR = 1e-10;
// An absolute cap (in the value's own units) on how far floating-point cleanup may move a value.
// Equal to `STEP_EPSILON_FACTOR` only by coincidence — the two are independent and may diverge.
const MAX_FLOATING_POINT_CLEANUP_DELTA = 1e-10;
// The repo compiles against es2022 Intl types, so model NumberFormat v3 options locally.
// Delete this once tsconfig.base.json includes es2023.
function hasNumberFormatRoundingOptions(format) {
return format?.maximumFractionDigits != null || format?.minimumFractionDigits != null || format?.maximumSignificantDigits != null || format?.minimumSignificantDigits != null || format?.roundingIncrement != null || format?.roundingMode != null || format?.roundingPriority != null;
}
function removeFloatingPointErrors(value, format) {
if (!Number.isFinite(value)) {
return value;
}
if (!hasNumberFormatRoundingOptions(format)) {
// Clean binary floating-point noise (e.g. `0.1 + 0.2`) without discarding legitimate
// precision. The cleanup is delta-bounded, so it cannot tell noise apart from real digits
// that fall within the same epsilon: arithmetic that produces sub-epsilon precision (e.g.
// accumulating a high-significance `step` past the first tick) is normalized to ~15
// significant digits.
const roundedValue = parseFloat(value.toPrecision(15));
const cleanupDelta = Math.abs(roundedValue - value);
// Cap the cleanup delta so `toPrecision(15)` cannot erase meaningful fractional values
// at large magnitudes, where the relative epsilon alone is too permissive. The trade-off is
// that from the `2^19` binade (~5.2e5) up, a single ULP exceeds the absolute cap, so genuine
// stepping noise is left uncleaned there (e.g. `1000000.1 + 0.2` stays `1000000.2999999999`)
// rather than risk corrupting real precision.
const cleanupTolerance = Math.min(Number.EPSILON * Math.max(1, Math.abs(value)), MAX_FLOATING_POINT_CLEANUP_DELTA);
return cleanupDelta <= cleanupTolerance ? roundedValue : value;
}
const formatter = (0, _formatNumber.getFormatter)('en-US', {
...format,
// These options alter only display decoration, not numeric rounding.
signDisplay: 'auto',
currencySign: 'standard',
notation: format.notation === 'compact' ? 'standard' : format.notation,
useGrouping: false
});
const roundedText = formatter.format(value);
const roundedValue = (0, _parse.parseNumber)(roundedText, 'en-US', format);
if (roundedValue === null) {
return value;
}
return formatter.format(roundedValue) === roundedText ? roundedValue : value;
}
function snapToStep(value, base, step, mode = 'directional') {
const stepSize = Math.abs(step);
const direction = Math.sign(step);
const tolerance = stepSize * STEP_EPSILON_FACTOR * direction;
const rawSteps = value - base + tolerance;
if (mode === 'nearest') {
return base + Math.round(rawSteps / step) * step;
}
const snappedSteps = direction > 0 ? Math.floor(rawSteps / stepSize) : Math.ceil(rawSteps / stepSize);
return base + snappedSteps * stepSize;
}
function toValidatedNumber(value, {
step,
minWithDefault,
maxWithDefault,
minWithZeroDefault,
format,
snapOnStep,
small,
clamp: shouldClamp
}) {
if (value === null) {
return value;
}
let nextValue = value;
if (step != null && snapOnStep && step !== 0) {
const base = small || minWithDefault === Number.MIN_SAFE_INTEGER ? minWithZeroDefault : minWithDefault;
// Snap before clamping so non-step-aligned boundaries stay reachable.
nextValue = snapToStep(nextValue, base, step, small ? 'nearest' : 'directional');
}
// Clamp before rounding so a value just outside a fractional boundary (e.g. `0.4` with
// `min: 0.6`) is pulled in range first, then clamp again after rounding in case rounding
// nudges it back out. Both passes are needed for non-integer bounds.
if (shouldClamp) {
nextValue = (0, _clamp.clamp)(nextValue, minWithDefault, maxWithDefault);
}
// Non-stepping values — parsed input (typing, paste, blur) and externally-supplied controlled
// values — involve no arithmetic here, so they carry no binary noise to clean and are returned
// verbatim to keep every digit, including values with more than 15 significant digits. Only
// stepping/snapping arithmetic can introduce noise (e.g. 0.7 + 0.1), which
// `removeFloatingPointErrors` cleans.
if (step == null && !hasNumberFormatRoundingOptions(format)) {
return nextValue;
}
const roundedValue = removeFloatingPointErrors(nextValue, format);
return shouldClamp ? (0, _clamp.clamp)(roundedValue, minWithDefault, maxWithDefault) : roundedValue;
}