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