import classNames from "classnames" ; import * as React from "react" ; import { polyfill } from "react-lifecycles-compat" ; import { IconName } from "@blueprintjs/icons" ; 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" ; export type NumericInputProps = INumericInputProps ; deprecated export interface INumericInputProps extends IntentProps , Props { default 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 ; default buttonPosition ? : typeof Position . LEFT | typeof Position . RIGHT | "none" ; default clampValueOnBlur ? : boolean ; default defaultValue ? : number | string ; default disabled ? : boolean ; /** Whether the numeric input should take up the full width of its container. */ fill ? : boolean ; /**
     * Ref handler that receives HTML `<input>` element backing this component.
     */ inputRef ? : IRef < HTMLInputElement > ; default large ? : boolean ; /**
     * Name of a Blueprint UI icon (or an icon element) to render on the left side of input.
     */ leftIcon ? : IconName | MaybeElement ; default locale ? : string ; default majorStepSize ? : number | null ; /** The maximum value of the input. */ max ? : number ; /** The minimum value of the input. */ min ? : number ; default 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 ; default selectAllOnFocus ? : boolean ; default selectAllOnIncrement ? : boolean ; default 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 , valueAsString ) : void ; /** The callback invoked when the value changes due to typing, arrow keys, or button clicks. */ onValueChange ? ( valueAsNumber , valueAsString , inputElement ) : 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 = [ "allowNumericCharactersOnly" , "buttonPosition" , "clampValueOnBlur" , "className" , "defaultValue" , "majorStepSize" , "minorStepSize" , "onButtonClick" , "onValueChange" , "selectAllOnFocus" , "selectAllOnIncrement" , "stepSize" , ] ; type ButtonEventHandlers = Required < Pick < React . HTMLAttributes < Element > , "onKeyDown" | "onMouseDown" > > ; @ polyfill export class NumericInput extends AbstractPureComponent2 < HTMLInputProps & NumericInputProps , INumericInputState > { 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 , state ) { const nextState = { prevMaxProp : props . max , prevMinProp : props . min , } ; const didMinChange = props . min !== state . prevMinProp ; const didMaxChange = props . max !== state . prevMaxProp ; const didBoundsChange = didMinChange || didMaxChange ; 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 ( 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 ; private static getStepMaxPrecision ( props ) { if ( props . minorStepSize != null ) { return Utils . countDecimalPlaces ( props . minorStepSize ) ; } else { return Utils . countDecimalPlaces ( props . stepSize ! ) ; } } private static roundAndClampValue ( value , stepMaxPrecision , min , max , delta = 0 , locale ) { 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 ) , } ; private didPasteEventJustOccur = false ; private delta = 0 ; public inputElement : HTMLInputElement | null = null ; private inputRef : IRef < HTMLInputElement > = 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 ( < ControlGroup className = { containerClasses } fill = { fill } > { buttonPosition === Position . LEFT && buttons } { this . renderInput ( ) } { buttonPosition === Position . RIGHT && buttons } < /ControlGroup>); } public componentDidUpdate ( prevProps , prevState ) { 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 ) ) { 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 ) { 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 ) ; } 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 ) ; } } } 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 ( < ButtonGroup className = { Classes . FIXED } key = "button-group" vertical = { true } > < Button aria - label = "increment" disabled = { disabled || isIncrementDisabled } icon = "chevron-up" intent = { intent } { ... this . incrementButtonHandlers } /> < Button aria - label = "decrement" disabled = { disabled || isDecrementDisabled } icon = "chevron-down" intent = { intent } { ... this . decrementButtonHandlers } /> < /ButtonGroup>); } private renderInput ( ) { const inputGroupHtmlProps = removeNonHTMLProps ( this . props , NON_HTML_PROPS , true ) ; return ( < InputGroup asyncControl = { this . props . asyncControl } autoComplete = "off" { ... inputGroupHtmlProps } intent = { this . state . currentImeInputInvalid ? Intent . DANGER : this . props . intent } inputRef = { this . inputRef } large = { this . props . large } leftIcon = { this . props . leftIcon } onFocus = { this . handleInputFocus } onBlur = { this . handleInputBlur } onChange = { this . handleInputChange } onCompositionEnd = { this . handleCompositionEnd } onCompositionUpdate = { this . handleCompositionUpdate } onKeyDown = { this . handleInputKeyDown } onKeyPress = { this . handleInputKeyPress } onPaste = { this . handleInputPaste } rightElement = { this . props . rightElement } value = { this . state . value } / > ) ; } private getButtonEventHandlers ( direction ) { return { onKeyDown : evt => { if ( ! this . props . disabled && Keys . isKeyboardClick ( evt . keyCode ) ) { this . handleButtonClick ( evt , direction ) ; } } , onMouseDown : evt => { if ( ! this . props . disabled ) { this . handleButtonClick ( evt , direction ) ; this . startContinuousChange ( ) ; } } , } ; } private handleButtonClick = ( e , direction ) => { const delta = this . updateDelta ( direction , e ) ; const nextValue = this . incrementValue ( delta ) ; this . props . onButtonClick ?. ( Number ( parseStringToStringNumber ( nextValue , this . props . locale ) ) , nextValue ) ; } ; private startContinuousChange ( ) { document . addEventListener ( "mouseup" , this . stopContinuousChange ) ; this . setTimeout ( ( ) => { this . intervalId = window . setInterval ( this . handleContinuousChange , NumericInput . CONTINUOUS_CHANGE_INTERVAL ) ; } , NumericInput . CONTINUOUS_CHANGE_DELAY ) ; } private stopContinuousChange = ( ) => { this . delta = 0 ; this . clearTimeouts ( ) ; clearInterval ( this . intervalId ) ; document . removeEventListener ( "mouseup" , this . stopContinuousChange ) ; } ; private handleContinuousChange = ( ) => { if ( this . props . min !== undefined || this . props . max !== undefined ) { const min = this . props . min ?? - Infinity ; const max = this . props . max ?? Infinity ; 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 ?. ( Number ( parseStringToStringNumber ( nextValue , this . props . locale ) ) , nextValue ) ; } ; private handleInputFocus = ( e ) => { this . setState ( { shouldSelectAfterUpdate : this . props . selectAllOnFocus ! } ) ; this . props . onFocus ?. ( e ) ; } ; private handleInputBlur = ( e ) => { this . setState ( { shouldSelectAfterUpdate : false } ) ; if ( this . props . clampValueOnBlur ) { const { value } = e . target as HTMLInputElement ; this . handleNextValue ( this . roundAndClampValue ( value ) ) ; } this . props . onBlur ?. ( e ) ; } ; private handleInputKeyDown = ( e ) => { if ( this . props . disabled || this . props . readOnly ) { return ; } const { keyCode } = e ; let direction ; if ( keyCode === Keys . ARROW_UP ) { direction = IncrementDirection . UP ; } else if ( keyCode === Keys . ARROW_DOWN ) { direction = IncrementDirection . DOWN ; } if ( direction !== undefined ) { e . preventDefault ( ) ; const delta = this . updateDelta ( direction , e ) ; this . incrementValue ( delta ) ; } this . props . onKeyDown ?. ( e ) ; } ; private handleCompositionEnd = ( e ) => { if ( this . props . allowNumericCharactersOnly ) { this . handleNextValue ( sanitizeNumericInput ( e . data , this . props . locale ) ) ; this . setState ( { currentImeInputInvalid : false } ) ; } } ; private handleCompositionUpdate = ( e ) => { if ( this . props . allowNumericCharactersOnly ) { const { data } = e ; const sanitizedValue = sanitizeNumericInput ( data , this . props . locale ) ; if ( sanitizedValue . length === 0 && data . length > 0 ) { this . setState ( { currentImeInputInvalid : true } ) ; } else { this . setState ( { currentImeInputInvalid : false } ) ; } } } ; private handleInputKeyPress = ( e ) => { if ( this . props . allowNumericCharactersOnly && ! isValidNumericKeyboardEvent ( e , this . props . locale ) ) { e . preventDefault ( ) ; } this . props . onKeyPress ?. ( e ) ; } ; private handleInputPaste = ( e ) => { this . didPasteEventJustOccur = true ; this . props . onPaste ?. ( e ) ; } ; private handleInputChange = ( e ) => { const { value } = e . target as HTMLInputElement ; let nextValue = value ; if ( this . props . allowNumericCharactersOnly && this . didPasteEventJustOccur ) { this . didPasteEventJustOccur = false ; nextValue = sanitizeNumericInput ( value , this . props . locale ) ; } this . handleNextValue ( nextValue ) ; this . setState ( { shouldSelectAfterUpdate : false } ) ; } ; private handleNextValue ( valueAsString ) { if ( this . props . value == null ) { this . setState ( { value : valueAsString } ) ; } this . props . onValueChange ?. ( Number ( parseStringToStringNumber ( valueAsString , this . props . locale ) ) , valueAsString , this . inputElement ) ; } private incrementValue ( delta ) { const currValue = this . state . value === NumericInput . VALUE_EMPTY ? NumericInput . VALUE_ZERO : this . state . value ; const nextValue = this . roundAndClampValue ( currValue , delta ) ; if ( nextValue !== this . state . value ) { this . handleNextValue ( nextValue ) ; this . setState ( { shouldSelectAfterUpdate : this . props . selectAllOnIncrement ! } ) ; } return nextValue ; } private getIncrementDelta ( direction , isShiftKeyPressed , isAltKeyPressed ) { const { majorStepSize , minorStepSize , stepSize } = this . props ; if ( isShiftKeyPressed && majorStepSize != null ) { return direction * majorStepSize ; } else if ( isAltKeyPressed && minorStepSize != null ) { return direction * minorStepSize ; } else { return direction * stepSize ! ; } } private roundAndClampValue ( value , delta = 0 ) { return NumericInput . roundAndClampValue ( value , this . state . stepMaxPrecision , this . props . min , this . props . max , delta , this . props . locale ) ; } private updateDelta ( direction , e ) { this . delta = this . getIncrementDelta ( direction , e . shiftKey , e . altKey ) ; return this . delta ; } }