import { useTheme } from '../../../context/ThemeContext' import Block from '../../Primitives/Block/Block' import PrimitiveInput from '../../Primitives/PrimitiveInput/PrimitiveInput' import PrimitiveButton from '../../Primitives/PrimitiveButton/PrimitiveButton' import InputLabels from '../utils/InputLabels/InputLabels' import InputFooter from '../utils/InputFooter/InputFooter' import type { NumberInputProps } from './types' import { NumberInputSize } from './types' import type { NumberInputTokensType } from './numberInput.tokens' import { useResponsiveTokens } from '../../../hooks/useResponsiveTokens' import { toPixels } from '../../../global-utils/GlobalUtils' import React, { useState, useId } from 'react' import { useBreakpoints } from '../../../hooks/useBreakPoints' import { BREAKPOINTS } from '../../../breakpoints/breakPoints' import FloatingLabels from '../utils/FloatingLabels/FloatingLabels' import { Triangle } from 'lucide-react' import { FOUNDATION_THEME } from '../../../tokens' import { useErrorShake } from '../../common/useErrorShake' import { getErrorShakeStyle, errorShakeAnimation, } from '../../common/error.animations' import styled from 'styled-components' import { sanitizeNumberInput, clampValueOnBlur, isValueOutsideRange, getRangeErrorMessage, incrementValue, decrementValue, } from './utils' const Wrapper = styled(Block)` ${errorShakeAnimation} ` const NumberInput = ({ value, onChange, min, max, step, error = false, errorMessage, required, disabled, size = NumberInputSize.MEDIUM, placeholder, sublabel, helpIconHintText, label, hintText, name, preventNegative = false, onBlur, onFocus, ...rest }: NumberInputProps) => { const numberInputTokens = useResponsiveTokens('NUMBER_INPUT') const { theme: blendTheme } = useTheme() // primary[50] reads as a near-white halo on a dark surface const focusRingColor = blendTheme === 'dark' ? FOUNDATION_THEME.colors.primary[800] : FOUNDATION_THEME.colors.primary[50] const generatedId = useId() const inputId = rest.id || generatedId const errorId = `${inputId}-error` const hintId = `${inputId}-hint` const [isFocused, setIsFocused] = useState(false) const [internalValue, setInternalValue] = useState('') const [internalError, setInternalError] = useState(false) const [internalErrorMessage, setInternalErrorMessage] = useState() const shouldShake = useErrorShake(error || internalError) const { breakPointLabel } = useBreakpoints(BREAKPOINTS) const isSmallScreen = breakPointLabel === 'sm' const inputFocusedOrWithValue = isFocused || value !== undefined const isSmallScreenWithLargeSize = isSmallScreen && size === NumberInputSize.LARGE const paddingX = numberInputTokens.inputContainer.padding.x[size] const paddingY = toPixels(numberInputTokens.inputContainer.padding.y[size]) + (isSmallScreenWithLargeSize ? 0.5 : 1) const ariaDescribedBy = [ hintText && !error ? hintId : null, error && errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || undefined const numericMin = min !== undefined ? Number(min) : undefined const numericMax = max !== undefined ? Number(max) : undefined const stepValue = step ?? 1 const currentNumericValue = value !== null && value !== undefined ? Number(value) : null const hasError = error || internalError const displayErrorMessage = errorMessage || internalErrorMessage const updateValue = ( newValue: number, clearError: boolean = true ): void => { const newValueString = String(newValue) if (currentNumericValue === newValue) return if (clearError) { setInternalError(false) setInternalErrorMessage(undefined) } setInternalValue(newValueString) onChange({ target: { value: newValueString, }, } as React.ChangeEvent) } const isUpButtonDisabled = numericMax !== undefined && (currentNumericValue === null ? (numericMin ?? 0) + stepValue > numericMax : currentNumericValue >= numericMax || currentNumericValue + stepValue > numericMax) const isDownButtonDisabled = currentNumericValue === null ? preventNegative || (numericMin !== undefined && (numericMin ?? 0) - stepValue < numericMin) : (numericMin !== undefined && (currentNumericValue <= numericMin || currentNumericValue - stepValue < numericMin)) || (preventNegative && (currentNumericValue <= 0 || currentNumericValue - stepValue < 0)) const displayValue = isFocused ? internalValue : value !== null && value !== undefined ? String(value) : '' return ( {(!isSmallScreen || size !== NumberInputSize.LARGE) && ( )} {label && isSmallScreenWithLargeSize && ( )} { const inputValue = e.target.value const sanitized = sanitizeNumberInput( inputValue, !preventNegative ) setInternalValue(sanitized) const numValue = sanitized === '' || sanitized === '-' ? null : Number(sanitized) const valueString = numValue !== null && !isNaN(numValue) ? String(numValue) : '' const currentValueString = currentNumericValue !== null ? String(currentNumericValue) : '' if (valueString === currentValueString) return if (numValue !== null && !isNaN(numValue)) { if ( isValueOutsideRange( numValue, numericMin, numericMax ) ) { setInternalError(true) setInternalErrorMessage( getRangeErrorMessage(numericMin, numericMax) ) } else { setInternalError(false) setInternalErrorMessage(undefined) } } else { setInternalError(false) setInternalErrorMessage(undefined) } onChange({ ...e, target: { ...e.target, value: valueString, }, } as React.ChangeEvent) }} step={step} min={numericMin} max={numericMax} required={required} role="spinbutton" aria-valuenow={ value !== null && value !== undefined && !isNaN(Number(value)) ? Number(value) : undefined } aria-valuemin={numericMin} aria-valuemax={numericMax} aria-required={required ? 'true' : undefined} aria-invalid={hasError ? 'true' : 'false'} aria-describedby={ariaDescribedBy} paddingX={paddingX} paddingTop={ label && isSmallScreenWithLargeSize && inputFocusedOrWithValue ? paddingY * 1.5 : paddingY } paddingBottom={ label && isSmallScreenWithLargeSize && inputFocusedOrWithValue ? paddingY / 2 : paddingY } borderRadius={ numberInputTokens.inputContainer.borderRadius[size] } border={ numberInputTokens.inputContainer.border[ hasError ? 'error' : 'default' ] } fontSize={numberInputTokens.inputContainer.fontSize[size]} fontWeight={ numberInputTokens.inputContainer.fontWeight[size] } color={ numberInputTokens.inputContainer.color[ disabled ? 'disabled' : 'default' ] } outline="none" width={'100%'} backgroundColor={ numberInputTokens.inputContainer.backgroundColor[ hasError ? 'error' : 'default' ] } transition="border 200ms ease-in-out, box-shadow 200ms ease-in-out, background-color 200ms ease-in-out" placeholderStyles={{ transition: 'opacity 150ms ease-out', opacity: isFocused ? 0 : 1, }} _hover={{ border: numberInputTokens.inputContainer.border[ hasError ? 'error' : 'hover' ], backgroundColor: numberInputTokens.inputContainer.backgroundColor[ hasError ? 'error' : 'hover' ], }} _focus={{ border: numberInputTokens.inputContainer.border[ hasError ? 'error' : 'focus' ], boxShadow: `0 0 0 3px ${focusRingColor}`, backgroundColor: 'rgba(239, 246, 255, 0.15)', }} _focusVisible={{ placeholderColor: 'transparent', }} disabled={disabled} _disabled={{ backgroundColor: numberInputTokens.inputContainer.backgroundColor .disabled, border: numberInputTokens.inputContainer.border .disabled, cursor: 'not-allowed', }} onKeyDown={(e) => { if (e.key === 'ArrowUp' && !isUpButtonDisabled) { e.preventDefault() const newValue = incrementValue( currentNumericValue, stepValue, numericMin, numericMax, preventNegative ) updateValue(newValue) } else if ( e.key === 'ArrowDown' && !isDownButtonDisabled ) { e.preventDefault() const newValue = decrementValue( currentNumericValue, stepValue, numericMin, numericMax, preventNegative ) updateValue(newValue) } }} onFocus={(e) => { setIsFocused(true) setInternalValue( value !== null && value !== undefined ? String(value) : '' ) onFocus?.(e) }} onBlur={(e) => { const clamped = clampValueOnBlur( internalValue, !preventNegative, numericMin, numericMax ) setInternalValue(clamped) setInternalError(false) setInternalErrorMessage(undefined) const clampedNumValue = clamped === '' ? null : Number(clamped) if ( clamped !== e.target.value && clampedNumValue !== currentNumericValue ) { onChange({ ...e, target: { ...e.target, value: clamped, }, } as React.ChangeEvent) } setIsFocused(false) onBlur?.(e) }} {...rest} /> { if (isUpButtonDisabled) return const newValue = incrementValue( currentNumericValue, stepValue, numericMin, numericMax, preventNegative ) updateValue(newValue) }} backgroundColor={ numberInputTokens.inputContainer.stepperButton .backgroundColor.default } width={ numberInputTokens.inputContainer.stepperButton .width[size] } height={ numberInputTokens.inputContainer.stepperButton .width[size] } contentCentered borderRadius={`0 ${numberInputTokens.inputContainer.borderRadius[size]} 0 0`} disabled={disabled || isUpButtonDisabled} _focus={{ backgroundColor: numberInputTokens.inputContainer.stepperButton .backgroundColor.focus, }} _hover={{ backgroundColor: numberInputTokens.inputContainer.stepperButton .backgroundColor.hover, }} _disabled={{ backgroundColor: disabled ? numberInputTokens.inputContainer.stepperButton .backgroundColor.disabled : numberInputTokens.inputContainer.stepperButton .backgroundColor.default, cursor: 'not-allowed', }} > {/* */} { if (isDownButtonDisabled) return const newValue = decrementValue( currentNumericValue, stepValue, numericMin, numericMax, preventNegative ) updateValue(newValue) }} backgroundColor={ numberInputTokens.inputContainer.stepperButton .backgroundColor.default } width={ numberInputTokens.inputContainer.stepperButton .width[size] } height={ numberInputTokens.inputContainer.stepperButton .width[size] } contentCentered borderRadius={`0 0px ${numberInputTokens.inputContainer.borderRadius[size]} 0`} _focus={{ backgroundColor: numberInputTokens.inputContainer.stepperButton .backgroundColor.focus, }} _hover={{ backgroundColor: numberInputTokens.inputContainer.stepperButton .backgroundColor.hover, borderTop: numberInputTokens.inputContainer.border .disabled, }} _disabled={{ backgroundColor: disabled ? numberInputTokens.inputContainer.stepperButton .backgroundColor.disabled : numberInputTokens.inputContainer.stepperButton .backgroundColor.default, borderTop: numberInputTokens.inputContainer.border .disabled, cursor: 'not-allowed', }} borderTop={ numberInputTokens.inputContainer.border.disabled } disabled={disabled || isDownButtonDisabled} > ) } export default NumberInput