UNPKG

25.1 kBTypeScriptView Raw
1/*
2 * Copyright 2017 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import classNames from "classnames";
18import * as React from "react";
19import { polyfill } from "react-lifecycles-compat";
20
21import { IconName } from "@blueprintjs/icons";
22
23import {
24 AbstractPureComponent2,
25 Classes,
26 DISPLAYNAME_PREFIX,
27 HTMLInputProps,
28 IntentProps,
29 Intent,
30 Props,
31 IRef,
32 Keys,
33 MaybeElement,
34 Position,
35 refHandler,
36 removeNonHTMLProps,
37 setRef,
38 Utils,
39} from "../../common";
40import * as Errors from "../../common/errors";
41import { ButtonGroup } from "../button/buttonGroup";
42import { Button } from "../button/buttons";
43import { ControlGroup } from "./controlGroup";
44import { InputGroup } from "./inputGroup";
45import {
46 clampValue,
47 getValueOrEmptyValue,
48 isValidNumericKeyboardEvent,
49 isValueNumeric,
50 parseStringToStringNumber,
51 sanitizeNumericInput,
52 toLocaleString,
53 toMaxPrecision,
54} from "./numericInputUtils";
55
56// eslint-disable-next-line deprecation/deprecation
57export type NumericInputProps = INumericInputProps;
58/** @deprecated use NumericInputProps */
59export interface INumericInputProps extends IntentProps, Props {
60 /**
61 * Whether to allow only floating-point number characters in the field,
62 * mimicking the native `input[type="number"]`.
63 *
64 * @default true
65 */
66 allowNumericCharactersOnly?: boolean;
67
68 /**
69 * Set this to `true` if you will be controlling the `value` of this input with asynchronous updates.
70 * These may occur if you do not immediately call setState in a parent component with the value from
71 * the `onChange` handler.
72 */
73 asyncControl?: boolean;
74
75 /**
76 * The position of the buttons with respect to the input field.
77 *
78 * @default Position.RIGHT
79 */
80 buttonPosition?: typeof Position.LEFT | typeof Position.RIGHT | "none";
81
82 /**
83 * Whether the value should be clamped to `[min, max]` on blur.
84 * The value will be clamped to each bound only if the bound is defined.
85 * Note that native `input[type="number"]` controls do *NOT* clamp on blur.
86 *
87 * @default false
88 */
89 clampValueOnBlur?: boolean;
90
91 /**
92 * In uncontrolled mode, this sets the default value of the input.
93 * Note that this value is only used upon component instantiation and changes to this prop
94 * during the component lifecycle will be ignored.
95 *
96 * @default ""
97 */
98 defaultValue?: number | string;
99
100 /**
101 * Whether the input is non-interactive.
102 *
103 * @default false
104 */
105 disabled?: boolean;
106
107 /** Whether the numeric input should take up the full width of its container. */
108 fill?: boolean;
109
110 /**
111 * Ref handler that receives HTML `<input>` element backing this component.
112 */
113 inputRef?: IRef<HTMLInputElement>;
114
115 /**
116 * If set to `true`, the input will display with larger styling.
117 * This is equivalent to setting `Classes.LARGE` via className on the
118 * parent control group and on the child input group.
119 *
120 * @default false
121 */
122 large?: boolean;
123
124 /**
125 * Name of a Blueprint UI icon (or an icon element) to render on the left side of input.
126 */
127 leftIcon?: IconName | MaybeElement;
128
129 /**
130 * The locale name, which is passed to the component to format the number and allowing to type the number in the specific locale.
131 * [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).
132 *
133 * @default ""
134 */
135 locale?: string;
136
137 /**
138 * The increment between successive values when <kbd>shift</kbd> is held.
139 * Pass explicit `null` value to disable this interaction.
140 *
141 * @default 10
142 */
143 majorStepSize?: number | null;
144
145 /** The maximum value of the input. */
146 max?: number;
147
148 /** The minimum value of the input. */
149 min?: number;
150
151 /**
152 * The increment between successive values when <kbd>alt</kbd> is held.
153 * Pass explicit `null` value to disable this interaction.
154 *
155 * @default 0.1
156 */
157 minorStepSize?: number | null;
158
159 /** The placeholder text in the absence of any value. */
160 placeholder?: string;
161
162 /**
163 * Element to render on right side of input.
164 * For best results, use a minimal button, tag, or small spinner.
165 */
166 rightElement?: JSX.Element;
167
168 /**
169 * Whether the entire text field should be selected on focus.
170 *
171 * @default false
172 */
173 selectAllOnFocus?: boolean;
174
175 /**
176 * Whether the entire text field should be selected on increment.
177 *
178 * @default false
179 */
180 selectAllOnIncrement?: boolean;
181
182 /**
183 * The increment between successive values when no modifier keys are held.
184 *
185 * @default 1
186 */
187 stepSize?: number;
188
189 /**
190 * The value to display in the input field.
191 */
192 value?: number | string;
193
194 /** The callback invoked when the value changes due to a button click. */
195 onButtonClick?(valueAsNumber: number, valueAsString: string): void;
196
197 /** The callback invoked when the value changes due to typing, arrow keys, or button clicks. */
198 onValueChange?(valueAsNumber: number, valueAsString: string, inputElement: HTMLInputElement | null): void;
199}
200
201export interface INumericInputState {
202 currentImeInputInvalid: boolean;
203 prevMinProp?: number;
204 prevMaxProp?: number;
205 shouldSelectAfterUpdate: boolean;
206 stepMaxPrecision: number;
207 value: string;
208}
209
210enum IncrementDirection {
211 DOWN = -1,
212 UP = +1,
213}
214
215const NON_HTML_PROPS: Array<keyof NumericInputProps> = [
216 "allowNumericCharactersOnly",
217 "buttonPosition",
218 "clampValueOnBlur",
219 "className",
220 "defaultValue",
221 "majorStepSize",
222 "minorStepSize",
223 "onButtonClick",
224 "onValueChange",
225 "selectAllOnFocus",
226 "selectAllOnIncrement",
227 "stepSize",
228];
229
230type ButtonEventHandlers = Required<Pick<React.HTMLAttributes<Element>, "onKeyDown" | "onMouseDown">>;
231
232@polyfill
233export class NumericInput extends AbstractPureComponent2<HTMLInputProps & NumericInputProps, INumericInputState> {
234 public static displayName = `${DISPLAYNAME_PREFIX}.NumericInput`;
235
236 public static VALUE_EMPTY = "";
237
238 public static VALUE_ZERO = "0";
239
240 public static defaultProps: NumericInputProps = {
241 allowNumericCharactersOnly: true,
242 buttonPosition: Position.RIGHT,
243 clampValueOnBlur: false,
244 defaultValue: NumericInput.VALUE_EMPTY,
245 large: false,
246 majorStepSize: 10,
247 minorStepSize: 0.1,
248 selectAllOnFocus: false,
249 selectAllOnIncrement: false,
250 stepSize: 1,
251 };
252
253 public static getDerivedStateFromProps(props: NumericInputProps, state: INumericInputState) {
254 const nextState = {
255 prevMaxProp: props.max,
256 prevMinProp: props.min,
257 };
258
259 const didMinChange = props.min !== state.prevMinProp;
260 const didMaxChange = props.max !== state.prevMaxProp;
261 const didBoundsChange = didMinChange || didMaxChange;
262
263 // in controlled mode, use props.value
264 // in uncontrolled mode, if state.value has not been assigned yet (upon initial mount), use props.defaultValue
265 const value = props.value?.toString() ?? state.value;
266 const stepMaxPrecision = NumericInput.getStepMaxPrecision(props);
267
268 const sanitizedValue =
269 value !== NumericInput.VALUE_EMPTY
270 ? NumericInput.roundAndClampValue(value, stepMaxPrecision, props.min, props.max, 0, props.locale)
271 : NumericInput.VALUE_EMPTY;
272
273 // if a new min and max were provided that cause the existing value to fall
274 // outside of the new bounds, then clamp the value to the new valid range.
275 if (didBoundsChange && sanitizedValue !== state.value) {
276 return { ...nextState, stepMaxPrecision, value: sanitizedValue };
277 }
278 return { ...nextState, stepMaxPrecision, value };
279 }
280
281 private static CONTINUOUS_CHANGE_DELAY = 300;
282
283 private static CONTINUOUS_CHANGE_INTERVAL = 100;
284
285 // Value Helpers
286 // =============
287 private static getStepMaxPrecision(props: HTMLInputProps & NumericInputProps) {
288 if (props.minorStepSize != null) {
289 return Utils.countDecimalPlaces(props.minorStepSize);
290 } else {
291 return Utils.countDecimalPlaces(props.stepSize!);
292 }
293 }
294
295 private static roundAndClampValue(
296 value: string,
297 stepMaxPrecision: number,
298 min: number | undefined,
299 max: number | undefined,
300 delta = 0,
301 locale: string | undefined,
302 ) {
303 if (!isValueNumeric(value, locale)) {
304 return NumericInput.VALUE_EMPTY;
305 }
306 const currentValue = parseStringToStringNumber(value, locale);
307 const nextValue = toMaxPrecision(Number(currentValue) + delta, stepMaxPrecision);
308 const clampedValue = clampValue(nextValue, min, max);
309 return toLocaleString(clampedValue, locale);
310 }
311
312 public state: INumericInputState = {
313 currentImeInputInvalid: false,
314 shouldSelectAfterUpdate: false,
315 stepMaxPrecision: NumericInput.getStepMaxPrecision(this.props),
316 value: getValueOrEmptyValue(this.props.value ?? this.props.defaultValue),
317 };
318
319 // updating these flags need not trigger re-renders, so don't include them in this.state.
320 private didPasteEventJustOccur = false;
321
322 private delta = 0;
323
324 public inputElement: HTMLInputElement | null = null;
325
326 private inputRef: IRef<HTMLInputElement> = refHandler(this, "inputElement", this.props.inputRef);
327
328 private intervalId?: number;
329
330 private incrementButtonHandlers = this.getButtonEventHandlers(IncrementDirection.UP);
331
332 private decrementButtonHandlers = this.getButtonEventHandlers(IncrementDirection.DOWN);
333
334 public render() {
335 const { buttonPosition, className, fill, large } = this.props;
336 const containerClasses = classNames(Classes.NUMERIC_INPUT, { [Classes.LARGE]: large }, className);
337 const buttons = this.renderButtons();
338 return (
339 <ControlGroup className={containerClasses} fill={fill}>
340 {buttonPosition === Position.LEFT && buttons}
341 {this.renderInput()}
342 {buttonPosition === Position.RIGHT && buttons}
343 </ControlGroup>
344 );
345 }
346
347 public componentDidUpdate(prevProps: NumericInputProps, prevState: INumericInputState) {
348 super.componentDidUpdate(prevProps, prevState);
349
350 if (prevProps.inputRef !== this.props.inputRef) {
351 setRef(prevProps.inputRef, null);
352 this.inputRef = refHandler(this, "inputElement", this.props.inputRef);
353 setRef(this.props.inputRef, this.inputElement);
354 }
355
356 if (this.state.shouldSelectAfterUpdate) {
357 this.inputElement?.setSelectionRange(0, this.state.value.length);
358 }
359
360 const didMinChange = this.props.min !== prevProps.min;
361 const didMaxChange = this.props.max !== prevProps.max;
362 const didBoundsChange = didMinChange || didMaxChange;
363 const didLocaleChange = this.props.locale !== prevProps.locale;
364 const didValueChange = this.state.value !== prevState.value;
365
366 if ((didBoundsChange && didValueChange) || (didLocaleChange && prevState.value !== NumericInput.VALUE_EMPTY)) {
367 // we clamped the value due to a bounds change, so we should fire the change callback
368 const valueToParse = didLocaleChange ? prevState.value : this.state.value;
369 const valueAsString = parseStringToStringNumber(valueToParse, prevProps.locale);
370 const localizedValue = toLocaleString(+valueAsString, this.props.locale);
371
372 this.props.onValueChange?.(+valueAsString, localizedValue, this.inputElement);
373 }
374 }
375
376 protected validateProps(nextProps: HTMLInputProps & NumericInputProps) {
377 const { majorStepSize, max, min, minorStepSize, stepSize, value } = nextProps;
378 if (min != null && max != null && min > max) {
379 console.error(Errors.NUMERIC_INPUT_MIN_MAX);
380 }
381 if (stepSize! <= 0) {
382 console.error(Errors.NUMERIC_INPUT_STEP_SIZE_NON_POSITIVE);
383 }
384 if (minorStepSize && minorStepSize <= 0) {
385 console.error(Errors.NUMERIC_INPUT_MINOR_STEP_SIZE_NON_POSITIVE);
386 }
387 if (majorStepSize && majorStepSize <= 0) {
388 console.error(Errors.NUMERIC_INPUT_MAJOR_STEP_SIZE_NON_POSITIVE);
389 }
390 if (minorStepSize && minorStepSize > stepSize!) {
391 console.error(Errors.NUMERIC_INPUT_MINOR_STEP_SIZE_BOUND);
392 }
393 if (majorStepSize && majorStepSize < stepSize!) {
394 console.error(Errors.NUMERIC_INPUT_MAJOR_STEP_SIZE_BOUND);
395 }
396
397 // controlled mode
398 if (value != null) {
399 const stepMaxPrecision = NumericInput.getStepMaxPrecision(nextProps);
400 const sanitizedValue = NumericInput.roundAndClampValue(
401 value.toString(),
402 stepMaxPrecision,
403 min,
404 max,
405 0,
406 this.props.locale,
407 );
408 const valueDoesNotMatch = sanitizedValue !== value.toString();
409 const localizedValue = toLocaleString(
410 Number(parseStringToStringNumber(value, this.props.locale)),
411 this.props.locale,
412 );
413 const isNotLocalized = sanitizedValue !== localizedValue;
414
415 if (valueDoesNotMatch && isNotLocalized) {
416 console.warn(Errors.NUMERIC_INPUT_CONTROLLED_VALUE_INVALID);
417 }
418 }
419 }
420
421 // Render Helpers
422 // ==============
423
424 private renderButtons() {
425 const { intent, max, min, locale } = this.props;
426 const value = parseStringToStringNumber(this.state.value, locale);
427 const disabled = this.props.disabled || this.props.readOnly;
428 const isIncrementDisabled = max !== undefined && value !== "" && +value >= max;
429 const isDecrementDisabled = min !== undefined && value !== "" && +value <= min;
430
431 return (
432 <ButtonGroup className={Classes.FIXED} key="button-group" vertical={true}>
433 <Button
434 aria-label="increment"
435 disabled={disabled || isIncrementDisabled}
436 icon="chevron-up"
437 intent={intent}
438 {...this.incrementButtonHandlers}
439 />
440 <Button
441 aria-label="decrement"
442 disabled={disabled || isDecrementDisabled}
443 icon="chevron-down"
444 intent={intent}
445 {...this.decrementButtonHandlers}
446 />
447 </ButtonGroup>
448 );
449 }
450
451 private renderInput() {
452 const inputGroupHtmlProps = removeNonHTMLProps(this.props, NON_HTML_PROPS, true);
453 return (
454 <InputGroup
455 asyncControl={this.props.asyncControl}
456 autoComplete="off"
457 {...inputGroupHtmlProps}
458 intent={this.state.currentImeInputInvalid ? Intent.DANGER : this.props.intent}
459 inputRef={this.inputRef}
460 large={this.props.large}
461 leftIcon={this.props.leftIcon}
462 onFocus={this.handleInputFocus}
463 onBlur={this.handleInputBlur}
464 onChange={this.handleInputChange}
465 onCompositionEnd={this.handleCompositionEnd}
466 onCompositionUpdate={this.handleCompositionUpdate}
467 onKeyDown={this.handleInputKeyDown}
468 onKeyPress={this.handleInputKeyPress}
469 onPaste={this.handleInputPaste}
470 rightElement={this.props.rightElement}
471 value={this.state.value}
472 />
473 );
474 }
475
476 // Callbacks - Buttons
477 // ===================
478
479 private getButtonEventHandlers(direction: IncrementDirection): ButtonEventHandlers {
480 return {
481 // keydown is fired repeatedly when held so it's implicitly continuous
482 onKeyDown: evt => {
483 // eslint-disable-next-line deprecation/deprecation
484 if (!this.props.disabled && Keys.isKeyboardClick(evt.keyCode)) {
485 this.handleButtonClick(evt, direction);
486 }
487 },
488 onMouseDown: evt => {
489 if (!this.props.disabled) {
490 this.handleButtonClick(evt, direction);
491 this.startContinuousChange();
492 }
493 },
494 };
495 }
496
497 private handleButtonClick = (e: React.MouseEvent | React.KeyboardEvent, direction: IncrementDirection) => {
498 const delta = this.updateDelta(direction, e);
499 const nextValue = this.incrementValue(delta);
500 this.props.onButtonClick?.(Number(parseStringToStringNumber(nextValue, this.props.locale)), nextValue);
501 };
502
503 private startContinuousChange() {
504 // The button's onMouseUp event handler doesn't fire if the user
505 // releases outside of the button, so we need to watch all the way
506 // from the top.
507 document.addEventListener("mouseup", this.stopContinuousChange);
508
509 // Initial delay is slightly longer to prevent the user from
510 // accidentally triggering the continuous increment/decrement.
511 this.setTimeout(() => {
512 this.intervalId = window.setInterval(this.handleContinuousChange, NumericInput.CONTINUOUS_CHANGE_INTERVAL);
513 }, NumericInput.CONTINUOUS_CHANGE_DELAY);
514 }
515
516 private stopContinuousChange = () => {
517 this.delta = 0;
518 this.clearTimeouts();
519 clearInterval(this.intervalId);
520 document.removeEventListener("mouseup", this.stopContinuousChange);
521 };
522
523 private handleContinuousChange = () => {
524 // If either min or max prop is set, when reaching the limit
525 // the button will be disabled and stopContinuousChange will be never fired,
526 // hence the need to check on each iteration to properly clear the timeout
527 if (this.props.min !== undefined || this.props.max !== undefined) {
528 const min = this.props.min ?? -Infinity;
529 const max = this.props.max ?? Infinity;
530 const valueAsNumber = Number(parseStringToStringNumber(this.state.value, this.props.locale));
531 if (valueAsNumber <= min || valueAsNumber >= max) {
532 this.stopContinuousChange();
533 return;
534 }
535 }
536 const nextValue = this.incrementValue(this.delta);
537 this.props.onButtonClick?.(Number(parseStringToStringNumber(nextValue, this.props.locale)), nextValue);
538 };
539
540 // Callbacks - Input
541 // =================
542
543 private handleInputFocus = (e: React.FocusEvent<HTMLInputElement>) => {
544 // update this state flag to trigger update for input selection (see componentDidUpdate)
545 this.setState({ shouldSelectAfterUpdate: this.props.selectAllOnFocus! });
546 this.props.onFocus?.(e);
547 };
548
549 private handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
550 // always disable this flag on blur so it's ready for next time.
551 this.setState({ shouldSelectAfterUpdate: false });
552
553 if (this.props.clampValueOnBlur) {
554 const { value } = e.target as HTMLInputElement;
555 this.handleNextValue(this.roundAndClampValue(value));
556 }
557
558 this.props.onBlur?.(e);
559 };
560
561 private handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
562 if (this.props.disabled || this.props.readOnly) {
563 return;
564 }
565
566 // eslint-disable-next-line deprecation/deprecation
567 const { keyCode } = e;
568
569 let direction: IncrementDirection | undefined;
570
571 if (keyCode === Keys.ARROW_UP) {
572 direction = IncrementDirection.UP;
573 } else if (keyCode === Keys.ARROW_DOWN) {
574 direction = IncrementDirection.DOWN;
575 }
576
577 if (direction !== undefined) {
578 // when the input field has focus, some key combinations will modify
579 // the field's selection range. we'll actually want to select all
580 // text in the field after we modify the value on the following
581 // lines. preventing the default selection behavior lets us do that
582 // without interference.
583 e.preventDefault();
584
585 const delta = this.updateDelta(direction, e);
586 this.incrementValue(delta);
587 }
588
589 this.props.onKeyDown?.(e);
590 };
591
592 private handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
593 if (this.props.allowNumericCharactersOnly) {
594 this.handleNextValue(sanitizeNumericInput(e.data, this.props.locale));
595 this.setState({ currentImeInputInvalid: false });
596 }
597 };
598
599 private handleCompositionUpdate = (e: React.CompositionEvent<HTMLInputElement>) => {
600 if (this.props.allowNumericCharactersOnly) {
601 const { data } = e;
602 const sanitizedValue = sanitizeNumericInput(data, this.props.locale);
603 if (sanitizedValue.length === 0 && data.length > 0) {
604 this.setState({ currentImeInputInvalid: true });
605 } else {
606 this.setState({ currentImeInputInvalid: false });
607 }
608 }
609 };
610
611 private handleInputKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
612 // we prohibit keystrokes in onKeyPress instead of onKeyDown, because
613 // e.key is not trustworthy in onKeyDown in all browsers.
614 if (this.props.allowNumericCharactersOnly && !isValidNumericKeyboardEvent(e, this.props.locale)) {
615 e.preventDefault();
616 }
617
618 this.props.onKeyPress?.(e);
619 };
620
621 private handleInputPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
622 this.didPasteEventJustOccur = true;
623 this.props.onPaste?.(e);
624 };
625
626 private handleInputChange = (e: React.FormEvent) => {
627 const { value } = e.target as HTMLInputElement;
628 let nextValue = value;
629 if (this.props.allowNumericCharactersOnly && this.didPasteEventJustOccur) {
630 this.didPasteEventJustOccur = false;
631 nextValue = sanitizeNumericInput(value, this.props.locale);
632 }
633
634 this.handleNextValue(nextValue);
635 this.setState({ shouldSelectAfterUpdate: false });
636 };
637
638 // Data logic
639 // ==========
640
641 private handleNextValue(valueAsString: string) {
642 if (this.props.value == null) {
643 this.setState({ value: valueAsString });
644 }
645
646 this.props.onValueChange?.(
647 Number(parseStringToStringNumber(valueAsString, this.props.locale)),
648 valueAsString,
649 this.inputElement,
650 );
651 }
652
653 private incrementValue(delta: number) {
654 // pretend we're incrementing from 0 if currValue is empty
655 const currValue = this.state.value === NumericInput.VALUE_EMPTY ? NumericInput.VALUE_ZERO : this.state.value;
656 const nextValue = this.roundAndClampValue(currValue, delta);
657
658 if (nextValue !== this.state.value) {
659 this.handleNextValue(nextValue);
660 this.setState({ shouldSelectAfterUpdate: this.props.selectAllOnIncrement! });
661 }
662
663 // return value used in continuous change updates
664 return nextValue;
665 }
666
667 private getIncrementDelta(direction: IncrementDirection, isShiftKeyPressed: boolean, isAltKeyPressed: boolean) {
668 const { majorStepSize, minorStepSize, stepSize } = this.props;
669
670 if (isShiftKeyPressed && majorStepSize != null) {
671 return direction * majorStepSize;
672 } else if (isAltKeyPressed && minorStepSize != null) {
673 return direction * minorStepSize;
674 } else {
675 return direction * stepSize!;
676 }
677 }
678
679 private roundAndClampValue(value: string, delta = 0) {
680 return NumericInput.roundAndClampValue(
681 value,
682 this.state.stepMaxPrecision,
683 this.props.min,
684 this.props.max,
685 delta,
686 this.props.locale,
687 );
688 }
689
690 private updateDelta(direction: IncrementDirection, e: React.MouseEvent | React.KeyboardEvent) {
691 this.delta = this.getIncrementDelta(direction, e.shiftKey, e.altKey);
692 return this.delta;
693 }
694}