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

[core] feat(NumericInput): support localization #4388

Merged
merged 20 commits into from
Nov 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 58 additions & 20 deletions packages/core/src/components/forms/numericInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import {
getValueOrEmptyValue,
isValidNumericKeyboardEvent,
isValueNumeric,
parseStringToStringNumber,
sanitizeNumericInput,
toLocaleString,
toMaxPrecision,
} from "./numericInputUtils";

Expand Down Expand Up @@ -112,6 +114,13 @@ export interface INumericInputProps extends IIntentProps, IProps {
*/
leftIcon?: IconName | MaybeElement;

/**
* The locale name, which is passed to the component to format the number and allowing to type the number in the specific locale.
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
* [See MDN documentation for more info about browser locale identification](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#Locale_identification_and_negotiation).
* @default ""
*/
locale?: string;

/**
* The increment between successive values when <kbd>shift</kbd> is held.
* Pass explicit `null` value to disable this interaction.
Expand Down Expand Up @@ -239,15 +248,14 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer

const sanitizedValue =
value !== NumericInput.VALUE_EMPTY
? NumericInput.roundAndClampValue(value, stepMaxPrecision, props.min, props.max)
? NumericInput.roundAndClampValue(value, stepMaxPrecision, props.min, props.max, 0, props.locale)
: NumericInput.VALUE_EMPTY;

// if a new min and max were provided that cause the existing value to fall
// outside of the new bounds, then clamp the value to the new valid range.
if (didBoundsChange && sanitizedValue !== state.value) {
return { ...nextState, stepMaxPrecision, value: sanitizedValue };
}

return { ...nextState, stepMaxPrecision, value };
}

Expand All @@ -270,12 +278,15 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
min: number | undefined,
max: number | undefined,
delta = 0,
locale: string | undefined,
) {
if (!isValueNumeric(value)) {
if (!isValueNumeric(value, locale)) {
return NumericInput.VALUE_EMPTY;
}
const nextValue = toMaxPrecision(parseFloat(value) + delta, stepMaxPrecision);
return clampValue(nextValue, min, max).toString();
const currentValue = parseStringToStringNumber(value, locale);
const nextValue = toMaxPrecision(Number(currentValue) + delta, stepMaxPrecision);
const clampedValue = clampValue(nextValue, min, max);
return toLocaleString(clampedValue, locale);
}

public state: INumericInputState = {
Expand Down Expand Up @@ -317,9 +328,16 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
const didMinChange = this.props.min !== prevProps.min;
const didMaxChange = this.props.max !== prevProps.max;
const didBoundsChange = didMinChange || didMaxChange;
if (didBoundsChange && this.state.value !== prevState.value) {
const didLocaleChange = this.props.locale !== prevProps.locale;
const didValueChange = this.state.value !== prevState.value;

if ((didBoundsChange && didValueChange) || (didLocaleChange && prevState.value !== NumericInput.VALUE_EMPTY)) {
// we clamped the value due to a bounds change, so we should fire the change callback
this.props.onValueChange?.(+this.state.value, this.state.value, this.inputElement);
const valueToParse = didLocaleChange ? prevState.value : this.state.value;
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
const valueAsString = parseStringToStringNumber(valueToParse, prevProps.locale);
const localizedValue = toLocaleString(+valueAsString, this.props.locale);

this.props.onValueChange?.(+valueAsString, localizedValue, this.inputElement);
}
}

Expand Down Expand Up @@ -347,8 +365,22 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
// controlled mode
if (value != null) {
const stepMaxPrecision = NumericInput.getStepMaxPrecision(nextProps);
const sanitizedValue = NumericInput.roundAndClampValue(value.toString(), stepMaxPrecision, min, max);
if (sanitizedValue !== value.toString()) {
const sanitizedValue = NumericInput.roundAndClampValue(
value.toString(),
stepMaxPrecision,
min,
max,
0,
this.props.locale,
);
const valueDoesNotMatch = sanitizedValue !== value.toString();
const localizedValue = toLocaleString(
Number(parseStringToStringNumber(value, this.props.locale)),
this.props.locale,
);
const isNotLocalized = sanitizedValue !== localizedValue;

if (valueDoesNotMatch && isNotLocalized) {
console.warn(Errors.NUMERIC_INPUT_CONTROLLED_VALUE_INVALID);
}
}
Expand All @@ -358,11 +390,12 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
// ==============

private renderButtons() {
const { intent, max, min } = this.props;
const { value } = this.state;
const { intent, max, min, locale } = this.props;
const value = parseStringToStringNumber(this.state.value, locale);
const disabled = this.props.disabled || this.props.readOnly;
const isIncrementDisabled = max !== undefined && value !== "" && +value >= max;
const isDecrementDisabled = min !== undefined && value !== "" && +value <= min;

return (
<ButtonGroup className={Classes.FIXED} key="button-group" vertical={true}>
<Button
Expand Down Expand Up @@ -435,7 +468,7 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
private handleButtonClick = (e: React.MouseEvent | React.KeyboardEvent, direction: IncrementDirection) => {
const delta = this.updateDelta(direction, e);
const nextValue = this.incrementValue(delta);
this.props.onButtonClick?.(+nextValue, nextValue);
this.props.onButtonClick?.(Number(parseStringToStringNumber(nextValue, this.props.locale)), nextValue);
};

private startContinuousChange() {
Expand Down Expand Up @@ -465,13 +498,14 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
if (this.props.min !== undefined || this.props.max !== undefined) {
const min = this.props.min ?? -Infinity;
const max = this.props.max ?? Infinity;
if (Number(this.state.value) <= min || Number(this.state.value) >= max) {
const valueAsNumber = Number(parseStringToStringNumber(this.state.value, this.props.locale));
if (valueAsNumber <= min || valueAsNumber >= max) {
this.stopContinuousChange();
return;
}
}
const nextValue = this.incrementValue(this.delta);
this.props.onButtonClick?.(+nextValue, nextValue);
this.props.onButtonClick?.(Number(parseStringToStringNumber(nextValue, this.props.locale)), nextValue);
};

// Callbacks - Input
Expand Down Expand Up @@ -528,15 +562,15 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer

private handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
if (this.props.allowNumericCharactersOnly) {
this.handleNextValue(sanitizeNumericInput(e.data));
this.handleNextValue(sanitizeNumericInput(e.data, this.props.locale));
this.setState({ currentImeInputInvalid: false });
}
};

private handleCompositionUpdate = (e: React.CompositionEvent<HTMLInputElement>) => {
if (this.props.allowNumericCharactersOnly) {
const { data } = e;
const sanitizedValue = sanitizeNumericInput(data);
const sanitizedValue = sanitizeNumericInput(data, this.props.locale);
if (sanitizedValue.length === 0 && data.length > 0) {
this.setState({ currentImeInputInvalid: true });
} else {
Expand All @@ -548,7 +582,7 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
private handleInputKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
// we prohibit keystrokes in onKeyPress instead of onKeyDown, because
// e.key is not trustworthy in onKeyDown in all browsers.
if (this.props.allowNumericCharactersOnly && !isValidNumericKeyboardEvent(e)) {
if (this.props.allowNumericCharactersOnly && !isValidNumericKeyboardEvent(e, this.props.locale)) {
e.preventDefault();
}

Expand All @@ -562,11 +596,10 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer

private handleInputChange = (e: React.FormEvent) => {
const { value } = e.target as HTMLInputElement;

let nextValue = value;
if (this.props.allowNumericCharactersOnly && this.didPasteEventJustOccur) {
this.didPasteEventJustOccur = false;
nextValue = sanitizeNumericInput(value);
nextValue = sanitizeNumericInput(value, this.props.locale);
}

this.handleNextValue(nextValue);
Expand All @@ -581,7 +614,11 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
this.setState({ value: valueAsString });
}

this.props.onValueChange?.(+valueAsString, valueAsString, this.inputElement);
this.props.onValueChange?.(
Number(parseStringToStringNumber(valueAsString, this.props.locale)),
valueAsString,
this.inputElement,
);
}

private incrementValue(delta: number) {
Expand Down Expand Up @@ -617,6 +654,7 @@ export class NumericInput extends AbstractPureComponent2<HTMLInputProps & INumer
this.props.min,
this.props.max,
delta,
this.props.locale,
);
}

Expand Down
81 changes: 72 additions & 9 deletions packages/core/src/components/forms/numericInputUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@

import { clamp } from "../../common/utils";

/** Returns the `decimal` number separator based on locale */
function getDecimalSeparator(locale: string) {
const testNumber = 1.9;
const testText = testNumber.toLocaleString(locale);
const one = (1).toLocaleString(locale);
const nine = (9).toLocaleString(locale);
const pattern = `${one}(.+)${nine}`;

const result = new RegExp(pattern).exec(testText);

return (result && result[1]) || ".";
}

export function toLocaleString(num: number, locale: string = "en-US") {
return sanitizeNumericInput(num.toLocaleString(locale), locale);
}

export function clampValue(value: number, min?: number, max?: number) {
// defaultProps won't work if the user passes in null, so just default
// to +/- infinity here instead, as a catch-all.
Expand All @@ -28,18 +45,52 @@ export function getValueOrEmptyValue(value: number | string = "") {
return value.toString();
}

/** Transform the localized character (ex. "") to a javascript recognizable string number (ex. "10.99") */
function transformLocalizedNumberToStringNumber(character: string, locale: string) {
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
const charactersMap = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(value => value.toLocaleString(locale));
const jsNumber = charactersMap.indexOf(character);

if (jsNumber !== -1) {
return jsNumber;
} else {
return character;
}
}

/** Transforms the localized number (ex. "10,99") to a javascript recognizable string number (ex. "10.99") */
export function parseStringToStringNumber(value: number | string, locale: string | undefined): string {
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
const valueAsString = "" + value;
if (parseFloat(valueAsString).toString() === value.toString()) {
return value.toString();
}

if (locale !== undefined) {
const decimalSeparator = getDecimalSeparator(locale);
const sanitizedString = sanitizeNumericInput(valueAsString, locale);

return sanitizedString
.split("")
.map(character => transformLocalizedNumberToStringNumber(character, locale))
.join("")
.replace(decimalSeparator, ".");
}

return value.toString();
}

/** Returns `true` if the string represents a valid numeric value, like "1e6". */
export function isValueNumeric(value: string) {
export function isValueNumeric(value: string, locale: string | undefined) {
// checking if a string is numeric in Typescript is a big pain, because
// we can't simply toss a string parameter to isFinite. below is the
// essential approach that jQuery uses, which involves subtracting a
// parsed numeric value from the string representation of the value. we
// need to cast the value to the `any` type to allow this operation
// between dissimilar types.
return value != null && (value as any) - parseFloat(value) + 1 >= 0;
const stringToStringNumber = parseStringToStringNumber(value, locale);
return value != null && (stringToStringNumber as any) - parseFloat(stringToStringNumber) + 1 >= 0;
}

export function isValidNumericKeyboardEvent(e: React.KeyboardEvent) {
export function isValidNumericKeyboardEvent(e: React.KeyboardEvent, locale: string | undefined) {
// unit tests may not include e.key. don't bother disabling those events.
if (e.key == null) {
return true;
Expand All @@ -63,7 +114,7 @@ export function isValidNumericKeyboardEvent(e: React.KeyboardEvent) {

// now we can simply check that the single character that wants to be printed
// is a floating-point number character that we're allowed to print.
return isFloatingPointNumericCharacter(e.key);
return isFloatingPointNumericCharacter(e.key, locale);
}

/**
Expand All @@ -77,9 +128,20 @@ export function isValidNumericKeyboardEvent(e: React.KeyboardEvent) {
* See here for the input[type="number"].value spec:
* https://www.w3.org/TR/2012/WD-html-markup-20120329/input.number.html#input.number.attrs.value
*/
const FLOATING_POINT_NUMBER_CHARACTER_REGEX = /^[Ee0-9\+\-\.]$/;
function isFloatingPointNumericCharacter(character: string) {
return FLOATING_POINT_NUMBER_CHARACTER_REGEX.test(character);
function isFloatingPointNumericCharacter(character: string, locale: string | undefined) {
if (locale !== undefined) {
const decimalSeparator = getDecimalSeparator(locale).replace(".", "\\.");
const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(value => value.toLocaleString(locale)).join("");
const localeFloatingPointNumericCharacterRegex = new RegExp(
"^[Ee" + numbers + "\\+\\-" + decimalSeparator + "]$",
);

return localeFloatingPointNumericCharacterRegex.test(character);
} else {
const floatingPointNumericCharacterRegex = /^[Ee0-9\+\-\.]$/;

return floatingPointNumericCharacterRegex.test(character);
}
}

/**
Expand Down Expand Up @@ -107,8 +169,9 @@ function convertFullWidthNumbersToAscii(value: string) {
/**
* Convert full-width (Japanese) numbers to ASCII, and strip all characters that are not valid floating-point numeric characters
*/
export function sanitizeNumericInput(value: string) {
export function sanitizeNumericInput(value: string, locale: string | undefined) {
const valueChars = convertFullWidthNumbersToAscii(value).split("");
const sanitizedValueChars = valueChars.filter(isFloatingPointNumericCharacter);
const sanitizedValueChars = valueChars.filter(valueChar => isFloatingPointNumericCharacter(valueChar, locale));

return sanitizedValueChars.join("");
}
Loading