Skip to content

Commit

Permalink
Merge pull request #200 from Hipo/feat/separate-number-input
Browse files Browse the repository at this point in the history
refactor(number-input): Separation of `NumberInput` and `Input`
  • Loading branch information
yasincaliskan authored Oct 13, 2022
2 parents 5901222 + 3696881 commit 10cca82
Show file tree
Hide file tree
Showing 11 changed files with 539 additions and 407 deletions.
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

0 comments on commit 10cca82

Please sign in to comment.