Skip to content
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

refactor(number-input): Separation of NumberInput and Input #200

Merged
merged 9 commits into from
Oct 13, 2022
11 changes: 11 additions & 0 deletions src/core/utils/number/numberConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const DEFAULT_THOUSANDTHS_SEPARATOR = ",";
const DEFAULT_DECIMAL_NUMBER_SEPARATOR = ".";
const DEFAULT_MINUS_SIGN = "-";
const DEFAULT_NUMERALS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];

export {
DEFAULT_THOUSANDTHS_SEPARATOR,
DEFAULT_DECIMAL_NUMBER_SEPARATOR,
DEFAULT_MINUS_SIGN,
DEFAULT_NUMERALS
};
45 changes: 35 additions & 10 deletions src/core/utils/number/numberUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
DEFAULT_DECIMAL_NUMBER_SEPARATOR,
DEFAULT_MINUS_SIGN,
DEFAULT_NUMERALS,
DEFAULT_THOUSANDTHS_SEPARATOR
} from "./numberConstants";
import {FormatNumberOptions, ParseNumberOptions} from "./numberTypes";

/**
Expand Down Expand Up @@ -27,6 +33,13 @@ const NAVIGATOR_LANGUAGE =
// eslint-disable-next-line no-negated-condition
typeof navigator !== "undefined" ? navigator.language : "en-GB";

function isIntlAPISupported() {
return (
typeof new Intl.NumberFormat() !== "undefined" &&
typeof new Intl.NumberFormat().formatToParts !== "undefined"
);
}

function formatNumber(formatNumberOptions: FormatNumberOptions) {
const {locale, ...otherOptions} = formatNumberOptions;
const options = {
Expand Down Expand Up @@ -120,21 +133,32 @@ function mapDigitToLocalVersion({locale = NAVIGATOR_LANGUAGE}: {locale?: string}
}

function getNumberSeparators(locale = NAVIGATOR_LANGUAGE) {
// eslint-disable-next-line no-magic-numbers
const parts = new Intl.NumberFormat(locale).formatToParts(-12345.6);
const THOUSANDTHS_SEPARATOR = parts.find((d) => d.type === "group")!.value;
const DECIMAL_NUMBER_SEPARATOR = parts.find((d) => d.type === "decimal")!.value;
const MINUS_SIGN = parts.find((d) => d.type === "minusSign")!.value;
let THOUSANDTHS_SEPARATOR = DEFAULT_THOUSANDTHS_SEPARATOR;
let DECIMAL_NUMBER_SEPARATOR = DEFAULT_DECIMAL_NUMBER_SEPARATOR;
let MINUS_SIGN = DEFAULT_MINUS_SIGN;

if (isIntlAPISupported()) {
// eslint-disable-next-line no-magic-numbers
const parts = new Intl.NumberFormat(locale).formatToParts(-12345.6);

THOUSANDTHS_SEPARATOR = parts.find((d) => d.type === "group")!.value;
DECIMAL_NUMBER_SEPARATOR = parts.find((d) => d.type === "decimal")!.value;
MINUS_SIGN = parts.find((d) => d.type === "minusSign")!.value;
}

return {THOUSANDTHS_SEPARATOR, DECIMAL_NUMBER_SEPARATOR, MINUS_SIGN};
}

function getLocaleNumerals(locale = NAVIGATOR_LANGUAGE) {
const numerals = new Intl.NumberFormat(locale, {useGrouping: false})
// eslint-disable-next-line no-magic-numbers
.format(9876543210)
.split("")
.reverse();
let numerals = DEFAULT_NUMERALS;

if (isIntlAPISupported()) {
numerals = new Intl.NumberFormat(locale, {useGrouping: false})
// eslint-disable-next-line no-magic-numbers
.format(9876543210)
.split("")
.reverse();
}

return numerals;
}
Expand Down Expand Up @@ -205,6 +229,7 @@ function isNonNegativeNumber(x: unknown): x is number {
export {
truncateDecimalPart,
isInteger,
isIntlAPISupported,
formatNumber,
parseNumber,
getDigit,
Expand Down
262 changes: 50 additions & 212 deletions src/form/input/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,221 +1,59 @@
import "./_input.scss";

import React, {useState, useEffect} from "react";
import React from "react";
import classNames from "classnames";

import {
getNumberSeparators,
parseNumber,
mapDigitsToLocalVersion,
formatNumber,
removeLeadingZeros,
getNegativeZero,
getThousandthSeparatorCount
} from "../../core/utils/number/numberUtils";
import {getInputLocalizationOptions, getInputParseNumberOptions} from "./util/inputUtils";
import {InputProps} from "./util/inputTypes";

const Input = React.forwardRef<HTMLInputElement, InputProps>(
/* eslint-disable complexity */
(props, ref) => {
const {
testid,
value,
type = "text",
isDisabled,
hasError,
customClassName,
leftIcon,
rightIcon,
localizationOptions = {},
role,
autoComplete = "off",
autoCorrect = "off",
onChange,
...rest
} = props;
const {
shouldFormatToLocaleString = false,
locale,
maximumFractionDigits = 0
} = getInputLocalizationOptions(localizationOptions);
const [
{
DECIMAL_NUMBER_SEPARATOR: decimalSeparatorForLocale,
MINUS_SIGN: minusSignForLocale,
LOCALE_NEGATIVE_ZERO: negativeZeroForLocale
},
setNumberSeparatorsForLocale
] = useState(() => ({
...getNumberSeparators(locale),
...getNegativeZero(locale)
}));
const isNumberInput = type === "number";
const inputContainerClassName = classNames(
"input-container",
customClassName,
`input-container--type-${type}`,
{
"input-container--is-disabled": isDisabled,
"input-container--has-error": hasError
}
);
let finalValue = value;

if (
!(
typeof maximumFractionDigits === "number" &&
Number.isInteger(maximumFractionDigits) &&
maximumFractionDigits >= 0
)
) {
throw new Error("maximumFractionDigits should be zero or a positive integer.");
}

if (isNumberInput && typeof value === "string" && shouldFormatToLocaleString) {
const [integerPart, decimalPart] = String(value).split(".");
const numberFormatter = formatNumber({
maximumFractionDigits,
locale
});

// IF there is a decimal part or the value ends with ".",
// make sure we add the decimal separator and map each digit on the decimal part to localized versions.
// We shouldn't use parseInt or parseFloat with numberFormat util here because that removes zeros on the decimal part and disallows users to write something like: 10.01 or 10.102
if (value.match(/\.$/)?.length || decimalPart) {
finalValue = `${numberFormatter(
parseInt(integerPart)
)}${decimalSeparatorForLocale}${mapDigitsToLocalVersion({locale}, decimalPart)}`;
} else if (integerPart) {
if (integerPart !== minusSignForLocale && integerPart !== negativeZeroForLocale) {
finalValue = numberFormatter(parseInt(integerPart));
} else {
finalValue = `${minusSignForLocale}${mapDigitsToLocalVersion(
{locale},
integerPart
)}`;
}
}
}

useEffect(() => {
setNumberSeparatorsForLocale({
...getNumberSeparators(locale),
...getNegativeZero(locale)
});
}, [locale]);

return (
<div role={role} className={inputContainerClassName} data-testid={testid}>
{leftIcon && (
<span className={"input-container__icon input-container__left-icon"}>
{leftIcon}
</span>
)}

<input
ref={ref}
className={"input"}
type={isNumberInput ? "text" : type}
autoComplete={autoComplete}
value={finalValue}
autoCorrect={autoCorrect}
disabled={isDisabled}
onChange={handleChange}
{...rest}
/>

{rightIcon && (
<span className={"input-container__icon input-container__right-icon"}>
{rightIcon}
</span>
)}
</div>
);

function handleChange(event: React.SyntheticEvent<HTMLInputElement>) {
if (isNumberInput) {
const {value: newValue} = event.currentTarget;

if (newValue) {
const formattedNewValue = parseNumber(
getInputParseNumberOptions({locale, maximumFractionDigits}),
newValue
);
// Number("-") returns NaN. Should allow minus sign as first character.
const isFormattedNewValueNotAValidNumber =
formattedNewValue === "" ||
(newValue !== "-" && Number.isNaN(Number(formattedNewValue)));
let finalEventValue = formattedNewValue ? String(formattedNewValue) : "";

// IF the parsed number is valid and there is a decimal separator,
// we need to save the number as it is so that decimal part doesn't disappear
if (
!isFormattedNewValueNotAValidNumber &&
formattedNewValue.match(/./)?.length
) {
finalEventValue = String(formattedNewValue);
} else if (isFormattedNewValueNotAValidNumber) {
// IF the parsed number is not valid, we revert back to the valid value
finalEventValue = value as string;
}

// IF 'shouldFormatToLocaleString' or 'maximumFractionDigits' are defined or the value is negative,
// value can't have leading zeros. Like 0,000,123 or 010.50 or -00
if (
!isFormattedNewValueNotAValidNumber &&
(shouldFormatToLocaleString ||
maximumFractionDigits > 0 ||
finalEventValue.includes("-"))
) {
finalEventValue = removeLeadingZeros(locale, finalEventValue);
}

// IF maximumFractionDigits is set as 0, value can not be negative zero
if (maximumFractionDigits === 0 && finalEventValue === "-0") {
finalEventValue = "0";
}

// IF shouldFormatToLocaleString is defined, caret position should calculate according to thoudsandths separator count
if (shouldFormatToLocaleString) {
const thousandthsSeparatorCount = getThousandthSeparatorCount(
finalEventValue
);
const prevValueThousandthsSeparatorCount = getThousandthSeparatorCount(
value as string
);
const element = event.currentTarget;
let caret = event.currentTarget.selectionStart || 0;

if (
finalEventValue &&
(String(value).length === finalEventValue.length + 1 ||
String(value).length === finalEventValue.length - 1)
) {
if (prevValueThousandthsSeparatorCount === thousandthsSeparatorCount + 1) {
caret -= 1;
} else if (
prevValueThousandthsSeparatorCount ===
thousandthsSeparatorCount - 1
) {
caret += 1;
}

window.requestAnimationFrame(() => {
element.selectionStart = caret;
element.selectionEnd = caret;
});
}
}

event.currentTarget.value = finalEventValue;
}
}

onChange(event);
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const {
testid,
type = "text",
isDisabled,
hasError,
customClassName,
leftIcon,
rightIcon,
role,
autoComplete = "off",
autoCorrect = "off",
...rest
} = props;
const inputContainerClassName = classNames(
"input-container",
customClassName,
`input-container--type-${type}`,
{
"input-container--is-disabled": isDisabled,
"input-container--has-error": hasError
}
}
/* eslint-enable complexity */
);
);

return (
<div role={role} className={inputContainerClassName} data-testid={testid}>
{leftIcon && (
<span className={"input-container__icon input-container__left-icon"}>
{leftIcon}
</span>
)}

<input
ref={ref}
className={"input"}
type={type}
autoComplete={autoComplete}
autoCorrect={autoCorrect}
disabled={isDisabled}
{...rest}
/>

{rightIcon && (
<span className={"input-container__icon input-container__right-icon"}>
{rightIcon}
</span>
)}
</div>
);
});

export default Input;
Loading