/* * Copyright 2017 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import classNames from "classnames"; import * as React from "react"; import { polyfill } from "react-lifecycles-compat"; // import { IconName } from "@blueprintjs/icons"; // Temporary type definition type IconName = string; import { AbstractPureComponent2, Classes, DISPLAYNAME_PREFIX, HTMLInputProps, IntentProps, Intent, Props, IRef, Keys, MaybeElement, Position, refHandler, removeNonHTMLProps, setRef, Utils, } from "../../common"; import * as Errors from "../../common/errors"; import { ButtonGroup } from "../button/buttonGroup"; import { Button } from "../button/buttons"; import { ControlGroup } from "./controlGroup"; import { InputGroup } from "./inputGroup"; import { clampValue, getValueOrEmptyValue, isValidNumericKeyboardEvent, isValueNumeric, parseStringToStringNumber, sanitizeNumericInput, toLocaleString, toMaxPrecision, } from "./numericInputUtils"; // eslint-disable-next-line deprecation/deprecation export type NumericInputProps = INumericInputProps; /** @deprecated use NumericInputProps */ export interface INumericInputProps extends IntentProps, Props { /** * Whether to allow only floating-point number characters in the field, * mimicking the native `input[type="number"]`. * * @default true */ allowNumericCharactersOnly?: boolean; /** * Set this to `true` if you will be controlling the `value` of this input with asynchronous updates. * These may occur if you do not immediately call setState in a parent component with the value from * the `onChange` handler. */ asyncControl?: boolean; /** * The position of the buttons with respect to the input field. * * @default Position.RIGHT */ buttonPosition?: typeof Position.LEFT | typeof Position.RIGHT | "none"; /** * Whether the value should be clamped to `[min, max]` on blur. * The value will be clamped to each bound only if the bound is defined. * Note that native `input[type="number"]` controls do *NOT* clamp on blur. * * @default false */ clampValueOnBlur?: boolean; /** * In uncontrolled mode, this sets the default value of the input. * Note that this value is only used upon component instantiation and changes to this prop * during the component lifecycle will be ignored. * * @default "" */ defaultValue?: number | string; /** * Whether the input is non-interactive. * * @default false */ disabled?: boolean; /** Whether the numeric input should take up the full width of its container. */ fill?: boolean; /** * Ref handler that receives HTML `` element backing this component. */ inputRef?: IRef; /** * If set to `true`, the input will display with larger styling. * This is equivalent to setting `Classes.LARGE` via className on the * parent control group and on the child input group. * * @default false */ large?: boolean; /** * Name of a Blueprint UI icon (or an icon element) to render on the left side of input. */ 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. * [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 shift is held. * Pass explicit `null` value to disable this interaction. * * @default 10 */ majorStepSize?: number | null; /** The maximum value of the input. */ max?: number; /** The minimum value of the input. */ min?: number; /** * The increment between successive values when alt is held. * Pass explicit `null` value to disable this interaction. * * @default 0.1 */ minorStepSize?: number | null; /** The placeholder text in the absence of any value. */ placeholder?: string; /** * Element to render on right side of input. * For best results, use a minimal button, tag, or small spinner. */ rightElement?: JSX.Element; /** * Whether the entire text field should be selected on focus. * * @default false */ selectAllOnFocus?: boolean; /** * Whether the entire text field should be selected on increment. * * @default false */ selectAllOnIncrement?: boolean; /** * The increment between successive values when no modifier keys are held. * * @default 1 */ stepSize?: number; /** * The value to display in the input field. */ value?: number | string; /** The callback invoked when the value changes due to a button click. */ onButtonClick?(valueAsNumber: number, valueAsString: string): void; /** The callback invoked when the value changes due to typing, arrow keys, or button clicks. */ onValueChange?(valueAsNumber: number, valueAsString: string, inputElement: HTMLInputElement | null): void; } export interface INumericInputState { currentImeInputInvalid: boolean; prevMinProp?: number; prevMaxProp?: number; shouldSelectAfterUpdate: boolean; stepMaxPrecision: number; value: string; } enum IncrementDirection { DOWN = -1, UP = +1, } const NON_HTML_PROPS: Array = [ "allowNumericCharactersOnly", "buttonPosition", "clampValueOnBlur", "className", "defaultValue", "majorStepSize", "minorStepSize", "onButtonClick", "onValueChange", "selectAllOnFocus", "selectAllOnIncrement", "stepSize", ]; type ButtonEventHandlers = Required, "onKeyDown" | "onMouseDown">>; @polyfill export class NumericInput extends AbstractPureComponent2 { public static displayName = `${DISPLAYNAME_PREFIX}.NumericInput`; public static VALUE_EMPTY = ""; public static VALUE_ZERO = "0"; public static defaultProps: NumericInputProps = { allowNumericCharactersOnly: true, buttonPosition: Position.RIGHT, clampValueOnBlur: false, defaultValue: NumericInput.VALUE_EMPTY, large: false, majorStepSize: 10, minorStepSize: 0.1, selectAllOnFocus: false, selectAllOnIncrement: false, stepSize: 1, }; public static getDerivedStateFromProps(props: NumericInputProps, state: INumericInputState) { const nextState = { prevMaxProp: props.max, prevMinProp: props.min, }; const didMinChange = props.min !== state.prevMinProp; const didMaxChange = props.max !== state.prevMaxProp; const didBoundsChange = didMinChange || didMaxChange; // in controlled mode, use props.value // in uncontrolled mode, if state.value has not been assigned yet (upon initial mount), use props.defaultValue const value = props.value?.toString() ?? state.value; const stepMaxPrecision = NumericInput.getStepMaxPrecision(props); const sanitizedValue = value !== NumericInput.VALUE_EMPTY ? 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 }; } private static CONTINUOUS_CHANGE_DELAY = 300; private static CONTINUOUS_CHANGE_INTERVAL = 100; // Value Helpers // ============= private static getStepMaxPrecision(props: HTMLInputProps & NumericInputProps) { if (props.minorStepSize != null) { return Utils.countDecimalPlaces(props.minorStepSize); } else { return Utils.countDecimalPlaces(props.stepSize!); } } private static roundAndClampValue( value: string, stepMaxPrecision: number, min: number | undefined, max: number | undefined, delta = 0, locale: string | undefined, ) { if (!isValueNumeric(value, locale)) { return NumericInput.VALUE_EMPTY; } 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 = { currentImeInputInvalid: false, shouldSelectAfterUpdate: false, stepMaxPrecision: NumericInput.getStepMaxPrecision(this.props), value: getValueOrEmptyValue(this.props.value ?? this.props.defaultValue), }; // updating these flags need not trigger re-renders, so don't include them in this.state. private didPasteEventJustOccur = false; private delta = 0; public inputElement: HTMLInputElement | null = null; private inputRef: IRef = refHandler(this, "inputElement", this.props.inputRef); private intervalId?: number; private incrementButtonHandlers = this.getButtonEventHandlers(IncrementDirection.UP); private decrementButtonHandlers = this.getButtonEventHandlers(IncrementDirection.DOWN); public render() { const { buttonPosition, className, fill, large } = this.props; const containerClasses = classNames(Classes.NUMERIC_INPUT, { [Classes.LARGE]: large }, className); const buttons = this.renderButtons(); return ( {buttonPosition === Position.LEFT && buttons} {this.renderInput()} {buttonPosition === Position.RIGHT && buttons} ); } public componentDidUpdate(prevProps: NumericInputProps, prevState: INumericInputState) { super.componentDidUpdate(prevProps, prevState); if (prevProps.inputRef !== this.props.inputRef) { setRef(prevProps.inputRef, null); this.inputRef = refHandler(this, "inputElement", this.props.inputRef); setRef(this.props.inputRef, this.inputElement); } if (this.state.shouldSelectAfterUpdate) { this.inputElement?.setSelectionRange(0, this.state.value.length); } const didMinChange = this.props.min !== prevProps.min; const didMaxChange = this.props.max !== prevProps.max; const didBoundsChange = didMinChange || didMaxChange; 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 const valueToParse = didLocaleChange ? prevState.value : this.state.value; const valueAsString = parseStringToStringNumber(valueToParse, prevProps.locale); const localizedValue = toLocaleString(+valueAsString, this.props.locale); this.props.onValueChange?.(+valueAsString, localizedValue, this.inputElement); } } protected validateProps(nextProps: HTMLInputProps & NumericInputProps) { const { majorStepSize, max, min, minorStepSize, stepSize, value } = nextProps; if (min != null && max != null && min > max) { console.error(Errors.NUMERIC_INPUT_MIN_MAX); } if (stepSize! <= 0) { console.error(Errors.NUMERIC_INPUT_STEP_SIZE_NON_POSITIVE); } if (minorStepSize && minorStepSize <= 0) { console.error(Errors.NUMERIC_INPUT_MINOR_STEP_SIZE_NON_POSITIVE); } if (majorStepSize && majorStepSize <= 0) { console.error(Errors.NUMERIC_INPUT_MAJOR_STEP_SIZE_NON_POSITIVE); } if (minorStepSize && minorStepSize > stepSize!) { console.error(Errors.NUMERIC_INPUT_MINOR_STEP_SIZE_BOUND); } if (majorStepSize && majorStepSize < stepSize!) { console.error(Errors.NUMERIC_INPUT_MAJOR_STEP_SIZE_BOUND); } // controlled mode if (value != null) { const stepMaxPrecision = NumericInput.getStepMaxPrecision(nextProps); 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); } } } // Render Helpers // ============== private renderButtons() { 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 (