import classNames from "classnames"; import * as React from "react"; import { IconName } from "reui-icons"; import { AbstractPureComponent, Classes, DISPLAYNAME_PREFIX, HTMLInputProps, IIntentProps, IProps, Keys, Position, removeNonHTMLProps, Utils, } from "../../common"; import * as Errors from "../../common/errors"; import { ButtonGroup } from "../button/buttonGroup"; import { Button } from "../button/buttons"; import { InputGroup } from "./inputGroup"; 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 | JSX.Element; /** The placeholder text in the absence of any value. */ placeholder?: 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; /** * 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 { isInputGroupFocused?: boolean; isButtonGroupFocused?: boolean; shouldSelectAfterUpdate?: boolean; stepMaxPrecision?: number; value?: string; } enum IncrementDirection { DOWN = -1, UP = +1, } export class NumericInput extends AbstractPureComponent { 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, }; private static DECREMENT_KEY = "decrement"; private static INCREMENT_KEY = "increment"; private static DECREMENT_ICON_NAME: IconName = "chevron-down"; private static INCREMENT_ICON_NAME: IconName = "chevron-up"; /** * A regex that matches a string of length 1 (i.e. a standalone character) * if and only if it is a floating-point number character as defined by W3C: * https://www.w3.org/TR/2012/WD-html-markup-20120329/datatypes.html#common.data.float * * Floating-point number characters are the only characters that can be * printed within a default input[type="number"]. This component should * behave the same way when this.props.allowNumericCharactersOnly = true. * 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 */ private static FLOATING_POINT_NUMBER_CHARACTER_REGEX = /^[Ee0-9\+\-\.]$/; private static CONTINUOUS_CHANGE_DELAY = 300; private static CONTINUOUS_CHANGE_INTERVAL = 100; private inputElement: HTMLInputElement; // updating these flags need not trigger re-renders, so don't include them in this.state. private didPasteEventJustOccur = false; private shouldSelectAfterUpdate = false; private delta = 0; private intervalId: number | null = null; public constructor(props?: HTMLInputProps & INumericInputProps, context?: any) { super(props, context); this.state = { stepMaxPrecision: this.getStepMaxPrecision(props), value: this.getValueOrEmptyValue(props.value), }; } public componentWillReceiveProps(nextProps: HTMLInputProps & INumericInputProps) { super.componentWillReceiveProps(nextProps); const value = this.getValueOrEmptyValue(nextProps.value); const didMinChange = nextProps.min !== this.props.min; const didMaxChange = nextProps.max !== this.props.max; const didBoundsChange = didMinChange || didMaxChange; const sanitizedValue = value !== NumericInput.VALUE_EMPTY ? this.getSanitizedValue(value, /* delta */ 0, nextProps.min, nextProps.max) : NumericInput.VALUE_EMPTY; const stepMaxPrecision = this.getStepMaxPrecision(nextProps); // 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 !== this.state.value) { this.setState({ stepMaxPrecision, value: sanitizedValue }); this.invokeValueCallback(sanitizedValue, this.props.onValueChange); } else { this.setState({ stepMaxPrecision, value }); } } public render() { const { buttonPosition, className, fill, large } = this.props; const inputGroupHtmlProps = removeNonHTMLProps( this.props, [ "allowNumericCharactersOnly", "buttonPosition", "clampValueOnBlur", "className", "large", "majorStepSize", "minorStepSize", "onButtonClick", "onValueChange", "selectAllOnFocus", "selectAllOnIncrement", "stepSize", ], true, ); const inputGroup = ( ); // the strict null check here is intentional; an undefined value should // fall back to the default button position on the right side. if (buttonPosition === "none" || buttonPosition === null) { // If there are no buttons, then the control group will render the // text field with squared border-radii on the left side, causing it // to look weird. This problem goes away if we simply don't nest within // a control group. return
{inputGroup}
; } else { const incrementButton = this.renderButton( NumericInput.INCREMENT_KEY, NumericInput.INCREMENT_ICON_NAME, this.handleIncrementButtonMouseDown, this.handleIncrementButtonKeyDown, this.handleIncrementButtonKeyUp, ); const decrementButton = this.renderButton( NumericInput.DECREMENT_KEY, NumericInput.DECREMENT_ICON_NAME, this.handleDecrementButtonMouseDown, this.handleDecrementButtonKeyDown, this.handleDecrementButtonKeyUp, ); const buttonGroup = ( {incrementButton} {decrementButton} ); const inputElems = buttonPosition === Position.LEFT ? [buttonGroup, inputGroup] : [inputGroup, buttonGroup]; const classes = classNames( Classes.NUMERIC_INPUT, Classes.CONTROL_GROUP, { [Classes.FILL]: fill, [Classes.LARGE]: large, }, className, ); return
{inputElems}
; } } public componentDidUpdate() { if (this.shouldSelectAfterUpdate) { this.inputElement.setSelectionRange(0, this.state.value.length); } } 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 renderButton( key: string, iconName: IconName, onMouseDown: React.MouseEventHandler, onKeyDown: React.KeyboardEventHandler, onKeyUp: React.KeyboardEventHandler, ) { return (