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
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;
92 changes: 0 additions & 92 deletions src/form/input/input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,95 +70,3 @@ describe("<Input />", () => {
expect(input).toBeDisabled();
});
});

describe('<Input type={"number"} />', () => {
const numberInputProps: InputProps = {
testid: "number-input",
name: "number-input",
type: "number",
localizationOptions: {
shouldFormatToLocaleString: true,
locale: "en-EN",
maximumFractionDigits: 2
},
onChange: jest.fn()
};

it("should render correctly", () => {
render(<Input {...numberInputProps} />);
});

it("should pass a11y test", async () => {
const {container} = render(<Input {...numberInputProps} />);

await testA11y(container, {rules: {label: {enabled: false}}});
});

it("should format value to with 'en-EN' locale", () => {
render(<Input {...numberInputProps} value={"1234567.89"} />);

const input = screen.getByRole("textbox");

expect(input).toHaveValue("1,234,567.89");
});

it("should remove leading zeros", () => {
render(<Input {...numberInputProps} value={"0001234.50"} />);

const input = screen.getByRole("textbox");

expect(input).toHaveValue("1,234.50");
});

it("should parse to scientific notation", () => {
render(<Input {...numberInputProps} />);

const input = screen.getByRole("textbox");

userEvent.type(input, "1,234,567.89");

expect(input).toHaveValue("1234567.89");
});

it("should have at most 2 decimal places", () => {
render(<Input {...numberInputProps} />);

const input = screen.getByRole("textbox");

userEvent.type(input, "100.55555");

expect(input).toHaveValue("100.55");
});

it("should not allow enter letter", () => {
render(<Input {...numberInputProps} />);

const input = screen.getByRole("textbox");

userEvent.type(input, "ABC");

expect(input).toHaveValue(undefined);
});

it("should not allow enter empty string", () => {
render(<Input {...numberInputProps} />);

const input = screen.getByRole("textbox");

userEvent.type(input, " ");

expect(input).toHaveValue(undefined);
});

it("should not allow negative zero without decimal part", () => {
render(
<Input {...numberInputProps} localizationOptions={{maximumFractionDigits: 0}} />
);

const input = screen.getByRole("textbox");

userEvent.type(input, "-0");

expect(input).toHaveValue("0");
});
});
Loading