/* * 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"; import { AbstractPureComponent2, Classes, DISPLAYNAME_PREFIX, HTMLInputProps, IIntentProps, IProps, Keys, MaybeElement, Position, removeNonHTMLProps, 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, isFloatingPointNumericCharacter, isValidNumericKeyboardEvent, isValueNumeric, toMaxPrecision, } from "./numericInputUtils"; export interface INumericInputProps extends IIntentProps, IProps { /** * Whether to allow only floating-point number characters in the field, * mimicking the native `input[type="number"]`. * @default true */ allowNumericCharactersOnly?: 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; /** * 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?: (ref: HTMLInputElement | null) => any; /** * 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 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): void; } export interface INumericInputState { prevMinProp?: number; prevMaxProp?: number; prevValueProp?: number | string; shouldSelectAfterUpdate: boolean; stepMaxPrecision: number; value: string; } enum IncrementDirection { DOWN = -1, UP = +1, } const NON_HTML_PROPS: Array = [ "allowNumericCharactersOnly", "buttonPosition", "clampValueOnBlur", "className", "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: INumericInputProps = { allowNumericCharactersOnly: true, buttonPosition: Position.RIGHT, clampValueOnBlur: false, large: false, majorStepSize: 10, minorStepSize: 0.1, selectAllOnFocus: false, selectAllOnIncrement: false, stepSize: 1, value: NumericInput.VALUE_EMPTY, }; public static getDerivedStateFromProps(props: INumericInputProps, state: INumericInputState) { const nextState = { prevMinProp: props.min, prevMaxProp: props.max, prevValueProp: props.value }; const didMinChange = props.min !== state.prevMinProp; const didMaxChange = props.max !== state.prevMaxProp; const didBoundsChange = didMinChange || didMaxChange; const didValuePropChange = props.value !== state.prevValueProp; const value = getValueOrEmptyValue(didValuePropChange ? props.value : state.value); const sanitizedValue = value !== NumericInput.VALUE_EMPTY ? NumericInput.getSanitizedValue(value, /* delta */ 0, props.min, props.max) : NumericInput.VALUE_EMPTY; const stepMaxPrecision = NumericInput.getStepMaxPrecision(props); // 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 }; } else { return { ...nextState, stepMaxPrecision, value }; } } private static CONTINUOUS_CHANGE_DELAY = 300; private static CONTINUOUS_CHANGE_INTERVAL = 100; // Value Helpers // ============= private static getStepMaxPrecision(props: HTMLInputProps & INumericInputProps) { if (props.minorStepSize != null) { return Utils.countDecimalPlaces(props.minorStepSize); } else { return Utils.countDecimalPlaces(props.stepSize); } } private static getSanitizedValue(value: string, stepMaxPrecision: number, min: number, max: number, delta = 0) { if (!isValueNumeric(value)) { return NumericInput.VALUE_EMPTY; } const nextValue = toMaxPrecision(parseFloat(value) + delta, stepMaxPrecision); return clampValue(nextValue, min, max).toString(); } public state: INumericInputState = { shouldSelectAfterUpdate: false, stepMaxPrecision: NumericInput.getStepMaxPrecision(this.props), value: getValueOrEmptyValue(this.props.value), }; // updating these flags need not trigger re-renders, so don't include them in this.state. private didPasteEventJustOccur = false; private delta = 0; private inputElement: HTMLInputElement | null = null; private intervalId: number | null = null; 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: INumericInputProps, prevState: INumericInputState) { super.componentDidUpdate(prevProps, prevState); if (this.state.shouldSelectAfterUpdate) { this.inputElement.setSelectionRange(0, this.state.value.length); } const didControlledValueChange = this.props.value !== prevProps.value; if (!didControlledValueChange && this.state.value !== prevState.value) { this.invokeValueCallback(this.state.value, this.props.onValueChange); } } protected validateProps(nextProps: HTMLInputProps & INumericInputProps) { const { majorStepSize, max, min, minorStepSize, stepSize } = nextProps; if (min != null && max != null && min > max) { throw new Error(Errors.NUMERIC_INPUT_MIN_MAX); } if (stepSize == null) { throw new Error(Errors.NUMERIC_INPUT_STEP_SIZE_NULL); } if (stepSize <= 0) { throw new Error(Errors.NUMERIC_INPUT_STEP_SIZE_NON_POSITIVE); } if (minorStepSize && minorStepSize <= 0) { throw new Error(Errors.NUMERIC_INPUT_MINOR_STEP_SIZE_NON_POSITIVE); } if (majorStepSize && majorStepSize <= 0) { throw new Error(Errors.NUMERIC_INPUT_MAJOR_STEP_SIZE_NON_POSITIVE); } if (minorStepSize && minorStepSize > stepSize) { throw new Error(Errors.NUMERIC_INPUT_MINOR_STEP_SIZE_BOUND); } if (majorStepSize && majorStepSize < stepSize) { throw new Error(Errors.NUMERIC_INPUT_MAJOR_STEP_SIZE_BOUND); } } // Render Helpers // ============== private renderButtons() { const { intent } = this.props; const disabled = this.props.disabled || this.props.readOnly; return (