import Block from '../../Primitives/Block/Block' import PrimitiveInput from '../../Primitives/PrimitiveInput/PrimitiveInput' import InputLabelsV2 from '../utils/InputLabels/InputLabelsV2' import InputFooterV2 from '../utils/InputFooter/InputFooterV2' import type { NumberInputV2Props } from './numberInputV2.types' import { AnyRef, InputSizeV2 } from '../inputV2.types' import type { NumberInputV2TokensType } from './numberInputV2.tokens' import { useResponsiveTokens } from '../../../hooks/useResponsiveTokens' import { addPxToValue, toPixels } from '../../../global-utils/GlobalUtils' import React, { forwardRef, useMemo, useState, useId, useRef, useLayoutEffect, } from 'react' import { useBreakpoints } from '../../../hooks/useBreakPoints' import { BREAKPOINTS } from '../../../breakpoints/breakPoints' import FloatingLabelsV2 from '../utils/FloatingLabelsV2/FloatingLabelsV2' import { sanitizeNumberInput, clampValueOnBlur, getEffectiveNumericValue, incrementValue, decrementValue, getSteppingBaseValue, getNumberInputDisplayValue, getInputFocusedOrWithValue, isSmallScreenWithLargeSize, getRangeErrorMessageIfOutside, getNumberInputHasError, getNumberInputDisplayErrorMessage, buildNumberInputAriaDescribedBy, getNumberInputLabelState, getNumberInputUnitState, computeIsUpButtonDisabled, computeIsDownButtonDisabled, sanitizedToCommittedValueString, rawNumericToComparableString, shouldSkipControlledChange, shouldEmitBlurChange, subscribeElementOffsetWidth, getNumberInputV2PaddingLeft, getNumberInputV2PaddingRight, getNumberInputV2UnitLengthErrorMessage, } from './utils' import { filterBlockedProps } from '../../../utils/prop-helpers' import { generateAccessibilityIds, setExternalRef } from '../utils/utils' import NumberInputV2Stepper from './NumberInputV2Stepper' import NumberInputV2Unit from './NumberInputV2Unit' import { NumberInputV2Direction } from './numberInputV2.types' const NumberInputV2 = forwardRef( ( { value, onChange, min, max, step, error = { show: false, message: '' }, required, disabled, size = InputSizeV2.MD, placeholder, label = { text: '', subtext: '' }, helpIconText, hintText, name, preventNegative = false, onBlur, onFocus, id: providedId, unitDirection = NumberInputV2Direction.RIGHT, unit = '', slot = { left: null, right: null, }, ...rest }, ref ) => { const numberInputTokens = useResponsiveTokens('NUMBER_INPUT_V2') const inputContainerTokens = numberInputTokens.inputContainer const filteredRest = filterBlockedProps(rest) const generatedId = useId() const inputId = providedId ?? generatedId const unitRef = useRef(null) const [measuredUnitWidth, setMeasuredUnitWidth] = useState(0) const [measuredLeftSlotWidth, setMeasuredLeftSlotWidth] = useState(0) const [measuredRightSlotWidth, setMeasuredRightSlotWidth] = useState(0) const setInputRef = (node: HTMLInputElement | null): void => { setExternalRef(ref as AnyRef, node) } const { errorId, hintId } = generateAccessibilityIds(inputId) const [isFocused, setIsFocused] = useState(false) const [isHovered, setIsHovered] = useState(false) const [internalValue, setInternalValue] = useState('') const { breakPointLabel } = useBreakpoints(BREAKPOINTS) const isSmallScreen = breakPointLabel === 'sm' const numericMin = min !== undefined ? Number(min) : undefined const numericMax = max !== undefined ? Number(max) : undefined const stepValue = step ?? 1 const rawNumericValue = value !== null && value !== undefined ? Number(value) : null /** Whitespace-only `unit` is treated as empty: show steppers, not an empty strip. */ const unitText = unit?.trim() ?? '' const unitLengthErrorMessage = getNumberInputV2UnitLengthErrorMessage(unit) const showUnit = Boolean(unitText) && unitLengthErrorMessage == null const steppingBaseValue = useMemo( () => getSteppingBaseValue(rawNumericValue, preventNegative), [rawNumericValue, preventNegative] ) const effectiveNumericValue = useMemo( () => getEffectiveNumericValue( rawNumericValue, preventNegative, numericMin, numericMax ), [rawNumericValue, preventNegative, numericMin, numericMax] ) const displayValue = getNumberInputDisplayValue( isFocused, internalValue, value, effectiveNumericValue ) const inputFocusedOrWithValue = getInputFocusedOrWithValue( isFocused, displayValue ) const smallScreenLarge = isSmallScreenWithLargeSize(isSmallScreen, size) const paddingY = toPixels(inputContainerTokens.paddingTop[size]) + (smallScreenLarge ? 0.5 : 1) const rangeErrorMessage = useMemo( () => getRangeErrorMessageIfOutside( rawNumericValue, numericMin, numericMax ), [rawNumericValue, numericMin, numericMax] ) const hasError = Boolean(unitLengthErrorMessage) || getNumberInputHasError( error?.show, error?.message, rangeErrorMessage ) const displayErrorMessage = unitLengthErrorMessage ?? getNumberInputDisplayErrorMessage( error?.show, error?.message, rangeErrorMessage ) const ariaDescribedBy = buildNumberInputAriaDescribedBy( hintText, hasError, hintId, errorId, displayErrorMessage ) const labelState = getNumberInputLabelState( disabled, hasError, isFocused ) const unitState = useMemo( () => getNumberInputUnitState( disabled, hasError, isFocused, isHovered ), [disabled, hasError, isFocused, isHovered] ) useLayoutEffect(() => { if (!showUnit) { setMeasuredUnitWidth(0) return } const el = unitRef.current if (!el) { setMeasuredUnitWidth(0) return } return subscribeElementOffsetWidth(el, setMeasuredUnitWidth) }, [showUnit, unitDirection, unitText, size, unitState, disabled]) useLayoutEffect(() => { setMeasuredLeftSlotWidth( slot.left ? toPixels(inputContainerTokens.slot.left.width[size]) : 0 ) }, [slot.left, size, inputContainerTokens]) useLayoutEffect(() => { setMeasuredRightSlotWidth( slot.right ? toPixels(inputContainerTokens.slot.right.width[size]) : 0 ) }, [slot.right, size, inputContainerTokens]) const updateValue = (newValue: number): void => { if (rawNumericValue === newValue) return const newValueString = String(newValue) const eventTarget = { value: newValueString, name, id: providedId, } setInternalValue(newValueString) onChange({ target: eventTarget, currentTarget: eventTarget, } as React.ChangeEvent) } const isUpButtonDisabled = computeIsUpButtonDisabled( numericMax, steppingBaseValue, numericMin, stepValue ) const isDownButtonDisabled = computeIsDownButtonDisabled( steppingBaseValue, preventNegative, numericMin, stepValue ) const bump = (direction: 'up' | 'down'): void => { if (direction === 'up') { if (isUpButtonDisabled) return updateValue( incrementValue( steppingBaseValue, stepValue, numericMin, numericMax, preventNegative ) ) } else { if (isDownButtonDisabled) return updateValue( decrementValue( steppingBaseValue, stepValue, numericMin, numericMax, preventNegative ) ) } } const borderState = hasError ? 'error' : 'default' const handleChange = (e: React.ChangeEvent): void => { const sanitized = sanitizeNumberInput( e.target.value, !preventNegative ) setInternalValue(sanitized) const valueString = sanitizedToCommittedValueString(sanitized) const currentValueString = rawNumericToComparableString(rawNumericValue) if (shouldSkipControlledChange(valueString, currentValueString)) return onChange({ ...e, target: { ...e.target, value: valueString, }, } as React.ChangeEvent) } const handleBlur = (e: React.FocusEvent): void => { const clamped = clampValueOnBlur( internalValue, !preventNegative, numericMin, numericMax ) setInternalValue(clamped) const clampedNumValue = clamped === '' ? null : Number(clamped) if ( shouldEmitBlurChange( clamped, e.target.value, clampedNumValue, rawNumericValue ) ) { onChange({ ...e, target: { ...e.target, value: clamped, }, } as React.ChangeEvent) } setIsFocused(false) onBlur?.(e) } const handleKeyDown = ( e: React.KeyboardEvent ): void => { if (e.key === 'ArrowUp' && !isUpButtonDisabled) { e.preventDefault() bump('up') } else if (e.key === 'ArrowDown' && !isDownButtonDisabled) { e.preventDefault() bump('down') } } const inputPaddingLeft = useMemo( () => getNumberInputV2PaddingLeft( inputContainerTokens, size, showUnit, unitDirection, measuredUnitWidth, measuredLeftSlotWidth, measuredRightSlotWidth ), [ showUnit, unitDirection, size, measuredUnitWidth, measuredLeftSlotWidth, measuredRightSlotWidth, inputContainerTokens, ] ) const inputPaddingRight = useMemo( () => getNumberInputV2PaddingRight( inputContainerTokens, size, showUnit, unitDirection, measuredUnitWidth, measuredLeftSlotWidth, measuredRightSlotWidth ), [ showUnit, unitDirection, size, measuredUnitWidth, measuredLeftSlotWidth, measuredRightSlotWidth, inputContainerTokens, ] ) /** Matches input text inset so the floating label aligns with the value, not the field edge (avoids unit / left slot). */ const floatingLabelLeftPadding = toPixels(inputPaddingLeft) return ( {(!isSmallScreen || size !== InputSizeV2.LG) && ( )} setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {Boolean(label.text?.trim()) && smallScreenLarge && ( )} {showUnit && unitDirection === NumberInputV2Direction.LEFT && ( )} {slot.left && showUnit && unitDirection === NumberInputV2Direction.LEFT && ( {slot.left} )} {slot.left && showUnit && unitDirection === NumberInputV2Direction.RIGHT && ( {slot.left} )} { setIsFocused(true) setInternalValue( effectiveNumericValue !== null ? String(effectiveNumericValue) : '' ) onFocus?.(e) }} onBlur={handleBlur} {...filteredRest} /> {slot.right && showUnit && unitDirection === NumberInputV2Direction.RIGHT && ( {slot.right} )} {slot.right && showUnit && unitDirection === NumberInputV2Direction.LEFT && ( {slot.right} )} {showUnit && unitDirection === NumberInputV2Direction.RIGHT && ( )} {!showUnit && ( )} ) } ) NumberInputV2.displayName = 'NumberInputV2' export default NumberInputV2