import PropTypes from 'prop-types' import React, { useEffect } from 'react' import { useRef } from 'react' import { useState } from 'react' import styled from 'styled-components' import { clamp } from '@xrengine/engine/src/common/functions/MathLerpFunctions' import { getStepSize, toPrecision } from '../../functions/utils' /** * @param value * @param precision * @returns */ function toPrecisionString(value, precision) { if (precision && precision <= 1) { const numDigits = Math.abs(Math.log10(precision)) const minimumFractionDigits = Math.min(numDigits, 2) const maximumFractionDigits = Math.max(minimumFractionDigits, numDigits) return value.toLocaleString('fullwide', { minimumFractionDigits, maximumFractionDigits, useGrouping: false }) } else { return value.toLocaleString('fullwide', { useGrouping: false }) } } const NumericInputContainer = styled.div` position: relative; display: flex; flex: 1; background-color: var(--inputBackground); border: 1px solid var(--inputOutline); border-radius: 4px; height: 24px; overflow: hidden; &:hover { border-color: var(--blueHover); } &:focus, &:focus-visible, &:focus-within { border-color: var(--blue); } &:disabled { background-color: var(--disabled); color: var(--disabledText); } ` const StyledNumericInput = styled.input` color: var(--textColor); background-color: var(--inputBackground); border: none; font-size: 12px; height: 22px; box-sizing: border-box; outline: none; padding: 0 4px; flex-grow: 1; min-width: 0; &:disabled { background-color: var(--disabled); color: var(--disabledText); } ` const NumericInputUnit = styled.div` color: var(--textColor); background-color: var(--inputBackground); padding-right: 4px; border-top-right-radius: 4px; border-bottom-right-radius: 4px; line-height: 20px; height: 100%; ` export interface NumericInputProp { className?: string unit?: any prefix?: any displayPrecision?: any value: number convertFrom?: any precision?: number mediumStep?: number onChange?: (n: number) => void onCommit?: Function smallStep?: number largeStep?: number min?: number max?: number convertTo?: any } const NumericInput = ({ className, unit, prefix, displayPrecision, value, convertFrom, precision, mediumStep, onChange, onCommit, smallStep, largeStep, min, max, convertTo, ...rest }: NumericInputProp) => { const [tempValue, setTempValue] = useState(null) const [focused, setFocused] = useState(false) const inputEl = useRef(null) const handleStep = (event, direction, focus = true) => { const stepSize = event ? getStepSize(event, smallStep, mediumStep, largeStep) : mediumStep const nextValue = parseFloat(inputEl?.current?.value ?? '0') + stepSize * direction const clampedValue = min != null && max != null ? clamp(nextValue, min, max) : nextValue const roundedValue = precision ? toPrecision(clampedValue, precision) : nextValue const finalValue = convertTo(roundedValue) if (onCommit) { onCommit(finalValue) } else { onChange?.(finalValue) } setTempValue( roundedValue.toLocaleString('fullwide', { useGrouping: false, minimumFractionDigits: 0, maximumFractionDigits: Math.abs(Math.log10(precision || 0)) + 1 }) ) setFocused(focus) } const increment = () => { handleStep(null, 1, false) } const decrement = () => { handleStep(null, -1, false) } const handleKeyPress = (event) => { if (event.key === 'Escape') { handleBlur() } if (event.key === 'Enter') { handleBlur() } let direction = 0 if (event.key === 'ArrowUp') { direction = 1 } else if (event.key === 'ArrowDown') { direction = -1 } if (!direction) return event.preventDefault() handleStep(event, direction, true) } const handleKeyDown = (event) => { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault() } } const handleChange = (event) => { const tempValue = event.target.value setTempValue(tempValue) setFocused(true) const parsedValue = parseFloat(tempValue) if (!Number.isNaN(parsedValue)) { const clampedValue = min != null && max != null ? clamp(parsedValue, min, max) : parsedValue const roundedValue = precision ? toPrecision(clampedValue, precision) : clampedValue const finalValue = convertTo(roundedValue) onChange?.(finalValue) } } const handleFocus = () => { setTempValue( convertFrom(value).toLocaleString('fullwide', { useGrouping: false, minimumFractionDigits: 0, maximumFractionDigits: Math.abs(Math.log10(precision || 0)) + 1 }) ) setFocused(true) } useEffect(() => { if (focused) inputEl?.current?.select() }, [focused]) const handleBlur = () => { setTempValue(null) setFocused(false) if (onCommit) { onCommit(value) } else { onChange?.(value) } } return ( {prefix ? prefix : null} {unit && {unit}} ) } ;(NumericInput as any).propTypes = { className: PropTypes.string, unit: PropTypes.node, smallStep: PropTypes.number.isRequired, mediumStep: PropTypes.number.isRequired, largeStep: PropTypes.number.isRequired, min: PropTypes.number.isRequired, max: PropTypes.number.isRequired, value: PropTypes.number.isRequired, onChange: PropTypes.func.isRequired, onCommit: PropTypes.func, convertTo: PropTypes.func.isRequired, convertFrom: PropTypes.func.isRequired, precision: PropTypes.number.isRequired, displayPrecision: PropTypes.number.isRequired } ;(NumericInput as any).defaultProps = { value: 0, smallStep: 0.025, mediumStep: 0.1, largeStep: 0.25, min: -Infinity, max: Infinity, displayPrecision: 0.001, precision: Number.EPSILON, convertTo: (value) => value, convertFrom: (value) => value } ;(NumericInput as any).defaultProps = { value: 0, smallStep: 0.025, mediumStep: 0.1, largeStep: 0.25, min: -Infinity, max: Infinity, displayPrecision: 0.001, precision: Number.EPSILON, convertTo: (value) => value, convertFrom: (value) => value } export default NumericInput