/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core'; import '@xh/hoist/desktop/register'; import {fmtNumber, NumericPrecision, parseNumber, Precision, ZeroPad} from '@xh/hoist/format'; import {numericInput} from '@xh/hoist/kit/blueprint'; import {wait} from '@xh/hoist/promise'; import {TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js'; import {composeRefs, getLayoutProps} from '@xh/hoist/utils/react'; import type {Property} from 'csstype'; import {debounce, isNaN, isNil, isNumber, round} from 'lodash'; import {KeyboardEventHandler, ReactElement, ReactNode, Ref, useLayoutEffect} from 'react'; export interface NumberInputProps extends HoistProps, LayoutProps, StyleProps, HoistInputProps { value?: number; /** True to focus the control on render. */ autoFocus?: boolean; /** True to commit on every change/keystroke, default false. */ commitOnChange?: boolean; /** True to insert commas in displayed value. */ displayWithCommas?: boolean; /** True to convert entries suffixed with k/m/b to thousands/millions/billions. */ enableShorthandUnits?: boolean; /** Ref handler that receives HTML element backing this component. */ inputRef?: Ref; /** Icon to display inline on the left side of the input. */ leftIcon?: ReactElement; /** * Minimum value. Note that this will govern the smallest value that this control can produce * via user input. Smaller values passed to it via props or a bound model will still be displayed. */ min?: number; /** * Maximum value. Note that this will govern the largest value that this control can produce * via user input. Larger values passed to it via props or a bound model will still be displayed. */ max?: number; /** Minor step size for increment/decrement handling. */ minorStepSize?: number; /** Major step size for increment/decrement handling. */ majorStepSize?: number; /** Callback for normalized keydown event. */ onKeyDown?: KeyboardEventHandler; /** Text to display when control is empty. */ placeholder?: string; /** Max decimal precision of the value, defaults to 4. Set to null for full, unrestricted precision. */ precision?: NumericPrecision; /** Element to display inline on the right side of the input. */ rightElement?: ReactNode; /** * Scale factor to apply when converting between the internal and external value. Useful for * cases such as handling a percentage value where the user would expect to see or input 20 but * the external value the input is bound to should be 0.2. Must be a factor of 10. * Defaults to 1 (no scaling applied). */ scaleFactor?: number; /** True to select contents when control receives focus. */ selectOnFocus?: boolean; /** Standard step size for increment/decrement handling. */ stepSize?: number; /** Alignment of entry text within control, default 'right'. */ textAlign?: Property.TextAlign; /** * Text appended to the rendered value within control when not editing. * Can be used to append e.g. "%" or a unit without need for an external right label. */ valueLabel?: string; /** @see NumberFormatOptions.zeroPad */ zeroPad?: ZeroPad; } /** * Number input, with optional support for formatting of display value, shorthand units, and more. * * This component is built on the Blueprint NumericInput and gets default increment/decrement * functionality from that component, based on the three stepSize props. * * This Hoist component hides the up/down buttons by default but keeps the keyboard handling. * Users can use the following keys to increment/decrement: * * ↑/↓ by one step * Shift + ↑/↓ by one major step * Alt + ↑/↓ by one minor step * * Set the corresponding stepSize prop(s) to null to disable this feature. */ export const [NumberInput, numberInput] = hoistCmp.withFactory({ displayName: 'NumberInput', className: 'xh-number-input', render(props, ref) { return useHoistInputModel(cmp, props, ref, NumberInputModel); } }); (NumberInput as any).hasLayoutSupport = true; //----------------------- // Implementation //----------------------- class NumberInputModel extends HoistInputModel { override xhImpl = true; override onLinked() { throwIf(Math.log10(this.scaleFactor) % 1 !== 0, 'scaleFactor must be a factor of 10'); } override get commitOnChange(): boolean { return withDefault(this.componentProps.commitOnChange, false); } get precision(): number { return withDefault(this.componentProps.precision, 4); } get scaleFactor(): number { return withDefault(this.componentProps.scaleFactor, 1); } onValueChange = (val, valAsString) => { this.noteValueChange(valAsString); }; /** TODO: Completely remove the debounce, or find and verify a reason why we need it set to 250. */ override doCommitOnChangeInternal() { debounce( () => super.doCommitOnChangeInternal(), withDefault(this.componentProps.commitOnChangeDebounce, 250) )(); } override toInternal(val: number): number { if (isNaN(val)) return val; if (isNil(val)) return null; return val * this.scaleFactor; } override toExternal(val: number): number { val = this.parseValue(val); if (isNaN(val)) return val; if (isNil(val)) return null; val = val / this.scaleFactor; // Round to scale corrected precision let {precision} = this; if (!isNil(precision)) { precision = precision + Math.log10(this.scaleFactor); val = round(val, precision); } return val; } override isValid(val: number): boolean { const {min, max} = this.componentProps; if (isNaN(val)) return false; if (val === null) return true; // Enforce min/max here on commit. BP props only limit the incremental step change if (!isNil(min) && val < min) return false; if (!isNil(max) && val > max) return false; return true; } onKeyDown: KeyboardEventHandler = ev => { if (ev.key === 'Enter') this.doCommit(); this.componentProps.onKeyDown?.(ev); }; formatRenderValue(value: number) { const {componentProps, precision} = this; if (value == null) return ''; if (this.hasFocus) { const ret = isNumber(value) && !isNil(precision) ? round(value, precision) : value; return ret?.toString(); } const {valueLabel, displayWithCommas} = componentProps, zeroPad = withDefault(componentProps.zeroPad, false), formattedVal = fmtNumber(value, { precision: precision as Precision, zeroPad, label: valueLabel, labelCls: null, asHtml: true }) as string; return displayWithCommas ? formattedVal : formattedVal.replace(/,/g, ''); } parseValue(value: number) { return parseNumber(value); } override noteFocused() { super.noteFocused(); if (this.componentProps.selectOnFocus) { // Deferred to allow any value conversion to complete and flush into input. wait().then(() => this.inputEl?.select()); } } } // Note: we don't use the `ref` here, but the presence of a second argument is required. const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { const {width, flex, ...layoutProps} = getLayoutProps(props), renderValue = model.formatRenderValue(model.renderValue); // BP workaround -- min, max, and stepsize can block Blueprint from rendering // intended value in underlying control -- ensure it is always shown. useLayoutEffect(() => { const input = model.inputEl; if (input) input.value = renderValue; }); // BP bases expected precision off of dps in minorStepSize, if specified. // The default BP value of 0.1 for this prop emits a console warning any time the input // value extends beyond 1 dp. Re-default here to sync with our `precision` prop. // See https://blueprintjs.com/docs/#core/components/numeric-input.numeric-precision const {precision} = model, majorStepSize = props.majorStepSize, minorStepSize = precision ? withDefault(props.minorStepSize, round(Math.pow(10, -precision), precision)) : null; // Render BP input. return numericInput({ value: renderValue, allowNumericCharactersOnly: !props.enableShorthandUnits && !props.displayWithCommas, buttonPosition: 'none', disabled: props.disabled, inputRef: composeRefs(model.inputRef as Ref, props.inputRef), leftIcon: props.leftIcon, min: props.min, max: props.max, minorStepSize, majorStepSize, placeholder: props.placeholder, rightElement: props.rightElement, stepSize: props.stepSize, tabIndex: props.tabIndex, autoFocus: props.autoFocus, id: props.id, className, style: { ...props.style, ...layoutProps, width: withDefault(width, 200), flex: withDefault(flex, null), textAlign: withDefault(props.textAlign, 'right') }, [TEST_ID]: props.testId, onBlur: model.onBlur, onFocus: model.onFocus, onKeyDown: model.onKeyDown, onValueChange: model.onValueChange }); });