/* * 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 {ChevronDown, ChevronUp} from "@blueprintjs/icons"; import { AbstractPureComponent, Button, ButtonGroup, Classes, ControlGroup, DISPLAYNAME_PREFIX, type HTMLInputProps, Intent, Position, refHandler, removeNonHTMLProps, setRef, Utils, } from "@blueprintjs/core"; import * as Errors from "@blueprintjs/core/src/common/errors"; import type {InputSharedProps} from "@blueprintjs/core/src/components/forms/inputSharedProps"; import { clampValue, getValueOrEmptyValue, isValidNumericKeyboardEvent, isValueNumeric, parseStringToStringNumber, sanitizeNumericInput, toLocaleString, } from "@blueprintjs/core/src/components/forms/numericInputUtils"; import {InputGroup2} from "../bpComponents/InputGroup2"; export interface NumericInputProps extends InputSharedProps { /** * 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; /** * 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; /** * 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; /** * 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; /** * If set to `true`, the input will display with smaller styling. * This is equivalent to setting `Classes.SMALL` via className on the * parent control group and on the child input group. * * @default false */ small?: 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 NumericInputState { 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">>; /** * Numeric input component. * * @see https://blueprintjs.com/docs/#core/components/numeric-input */ export class NumericInput extends AbstractPureComponent { public static displayName = `${DISPLAYNAME_PREFIX}.NumericInput`; public static VALUE_EMPTY = ""; public static VALUE_ZERO = "0"; private numericInputId = Utils.uniqueId("numericInput"); 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, small: false, stepSize: 1, }; public static getDerivedStateFromProps(props: NumericInputProps, state: NumericInputState) { 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 nextValue = (Number(currentValue) + delta); const clampedValue = clampValue(nextValue, min, max); return toLocaleString(clampedValue, locale); } public state: NumericInputState = { 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: React.Ref = refHandler(this, "inputElement", this.props.inputRef); private intervalId?: number; private incrementButtonHandlers = this.getButtonEventHandlers(IncrementDirection.UP); private decrementButtonHandlers = this.getButtonEventHandlers(IncrementDirection.DOWN); private getCurrentValueAsNumber = () => Number(parseStringToStringNumber(this.state.value, this.props.locale)); public render() { const { buttonPosition, className, fill, large, small } = this.props; const containerClasses = classNames( Classes.NUMERIC_INPUT, { [Classes.LARGE]: large, [Classes.SMALL]: small }, className, ); const buttons = this.renderButtons(); return ( {buttonPosition === Position.LEFT && buttons} {this.renderInput()} {buttonPosition === Position.RIGHT && buttons} ); } public componentDidUpdate(prevProps: NumericInputProps, prevState: NumericInputState) { 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 (