import { NumberField as NumberFieldType, SchemaFieldProps, Suggestion } from '@vev/utils'; import React, { useEffect, useRef, useState } from 'react'; import { SilkeCssNumberField } from '../../silke-css-number-field'; import { SilkeBox } from '../../silke-box'; import { SilkeSlider } from '../../silke-slider'; import { SilkeText, SilkeTextSmall } from '../../silke-text'; import { SilkeIconTooltip } from '../../silke-tooltip'; import { SilkeIcon } from '../../silke-icon'; import { SilkeButton } from '../../silke-button'; import { SilkePopover } from '../../silke-popover'; import { getTitle } from './utils/schema-util'; import { SilkeTextFieldOutline } from '../../silke-text-field/silke-text-field-outline'; import { SilkeTextFieldItem } from '../../silke-text-field'; const TEXT_FIELD_WIDTH = 36; const NumberField = ( props: SchemaFieldProps> & { format?: string; noLabel?: boolean; }, ) => { const textFieldRef = useRef(null); const dropdownRef = useRef(null); const [showSuggestions, setShowSuggestions] = useState(false); const [errorMessage, setErrorMessage] = useState(); const { value, onChange, schema, disabled, schema: { options: { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, display = 'input', } = {}, } = {}, ...rest } = props || {}; const scale = props.schema?.options?.scale || 1; const formats = schema.options?.format ? [schema.options.format] : schema.options?.formats || ['None']; const isNoneFormat = formats.length === 1 && formats[0] === 'None'; const precision = schema.options?.precision ?? (isNoneFormat ? 0 : undefined); let format = props.schema?.options?.format || props.schema?.options?.formats?.[0] || ''; if (format === 'None') format = ''; // Need to round number to avoid floating point precision issues const scaledValue = value ? Math.round(value * scale * 1000) / 1000 : value; useEffect(() => { if (scaledValue) { if (scaledValue < min) return setErrorMessage(`Value must be greater than ${min}`); if (scaledValue > max) return setErrorMessage(`Value must be less than ${max}`); } setErrorMessage(undefined); }, [scaledValue, min, max]); const round = (value: number) => { if (value < 1) return +value.toFixed(2); if (value > 99) return Math.round(value); return +value.toFixed(1); }; const displayValue = scale !== 1 ? `${scaledValue ?? 0}${format}` : `${value ?? '0'}${format}`; const suggestion = schema.options?.suggestions?.find((s: Suggestion) => { return s.value === `${value}`; }); if (schema.options?.suggestions && schema.options?.suggestions.length) { if (suggestion) { return ( {getTitle(schema)} {schema.description && } {suggestion.label} { onChange(null); }} /> ); } return ( { setShowSuggestions(true); }} onBlur={(e) => { if (!dropdownRef.current?.contains(e.relatedTarget as Node)) { setShowSuggestions(false); } }} onChange={(v: string) => { const num = parseFloat(v); if (!isNaN(num)) { onChange(scale !== 1 ? num / scale : num); } }} /> {showSuggestions && ( {schema.options?.suggestions.map((suggestion: Suggestion) => { return ( { setShowSuggestions(false); (onChange as unknown as (v: string) => void)(suggestion.value); }} > {suggestion.icon && (suggestion.icon as React.ReactNode)} {suggestion.label} ); })} )} ); } if (display === 'slider') { const sliderValue = scaledValue ?? 0; const sliderFormats = schema.options?.formats || (format ? [format] : ['None']); const isNoneFmt = sliderFormats.length === 1 && sliderFormats[0] === 'None'; const sliderPrecision = schema.options?.precision ?? (isNoneFmt ? 0 : undefined); const sliderDisplayValue = scale !== 1 ? `${sliderValue}${format}` : `${value ?? ''}`; return ( { const val = (percentage * (max - min) + min) / scale; onChange(round(val)); }} /> { const num = parseFloat(v); if (!isNaN(num)) { onChange(scale !== 1 ? num / scale : num); } }} style={{ width: 52 }} /> ); } if (display === 'range-slider') { // min/max are in display (scaled) space, rangeValue holds stored values const storedMin = min / scale; const storedMax = max / scale; const rangeValue = Array.isArray(value) ? value : [storedMin, storedMax]; const scaledMin = Math.round((rangeValue[0] ?? storedMin) * scale * 1000) / 1000; const scaledMax = Math.round((rangeValue[1] ?? storedMax) * scale * 1000) / 1000; const normalizedMin = (scaledMin - min) / (max - min); const normalizedMax = (scaledMax - min) / (max - min); // Range slider produces [number, number] but onChange is typed for single number const onRangeChange = onChange as unknown as (v: number[]) => void; const rangeFormats = schema.options?.formats || (format ? [format] : ['None']); const isNoneFmt = rangeFormats.length === 1 && rangeFormats[0] === 'None'; const rangePrecision = schema.options?.precision ?? (isNoneFmt ? 0 : undefined); return ( { const num = parseFloat(v); if (!isNaN(num)) { const stored = scale !== 1 ? num / scale : num; onRangeChange([stored, rangeValue[1]]); } }} style={{ width: 44 }} /> { if (Array.isArray(percentages)) { const newMin = round((percentages[0] * (max - min) + min) / scale); const newMax = round((percentages[1] * (max - min) + min) / scale); onRangeChange([newMin, newMax]); } }} /> { const num = parseFloat(v); if (!isNaN(num)) { const stored = scale !== 1 ? num / scale : num; onRangeChange([rangeValue[0], stored]); } }} style={{ width: 44 }} /> ); } return ( { const num = parseFloat(v); // If value is purely numeric (format is None), store as number if (!isNaN(num) && v === `${num}`) { onChange(scale !== 1 ? num / scale : num); } else if (!isNaN(num) && format && scale !== 1) { onChange(num / scale); } else if (!isNaN(num) && !isNoneFormat) { // Format has a unit (e.g. 's', '%') — strip the unit and store as number onChange(scale !== 1 ? num / scale : num); } else { // Value includes a unit suffix (e.g. '20px') — store as-is (onChange as unknown as (v: string) => void)(v); } }} /> ); }; export default NumberField;