// Numeric.tsx import React, { createContext, useContext, useCallback, PropsWithChildren } from 'react'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { cn } from '../../lib/utils'; import { Input } from '../Input/Input'; import { Slider } from '../Slider/Slider'; import { DoubleSlider } from '../DoubleSlider/DoubleSlider'; import { Button } from '../Button/Button'; import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; // Calculate decimal places based on step const getDecimalPlaces = (step: number): number => { if (Number.isInteger(step)) { return 0; } const stepStr = step.toString(); if (stepStr.includes('.')) { return stepStr.split('.')[1].length; } return 0; }; interface NumericMetaContextValue { mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper'; singleValue: number; doubleValue: [number, number]; setSingleValue: (val: number) => void; setDoubleValue: (vals: [number, number]) => void; min: number; max: number; step: number; } const NumericMetaContext = createContext(null); /* ------------------------------------------------------------------------- 1) Container ---------------------------------------------------------------------------*/ interface NumericMetaContainerProps { mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper'; value?: number; // for controlled single-value usage from parent defaultValue?: number; // for uncontrolled single-value usage values?: [number, number]; // for controlled double-range usage from parent defaultValues?: [number, number]; // for uncontrolled double-range usage onChange?: (val: number | [number, number]) => void; min?: number; max?: number; step?: number; className?: string; } function NumericMetaContainer({ mode, value, defaultValue, values, defaultValues, onChange, min = 0, max = 100, step = 1, className, children, }: PropsWithChildren) { // Calculate default values based on min and max const calculatedDefaultValue = defaultValue ?? min + (max - min) / 2; const calculatedDefaultValues = defaultValues ?? [ min + (max - min) * 0.3, min + (max - min) * 0.7, ]; // Use useControllableState for both single and double values const [internalSingleValue, setInternalSingleValue] = useControllableState({ prop: mode === 'number' || mode === 'singleRange' || mode === 'stepper' ? value : undefined, defaultProp: calculatedDefaultValue, onChange: newVal => { if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') { onChange?.(newVal); } }, }); const [internalDoubleValue, setInternalDoubleValue] = useControllableState({ prop: mode === 'doubleRange' ? values : undefined, defaultProp: calculatedDefaultValues, onChange: newVals => { if (mode === 'doubleRange') { onChange?.(newVals); } }, }); const handleSingleChange = useCallback( (newVal: number) => { setInternalSingleValue(newVal); }, [setInternalSingleValue] ); const handleDoubleChange = useCallback( (newVals: [number, number]) => { setInternalDoubleValue(newVals); }, [setInternalDoubleValue] ); return (
{children}
); } /* ------------------------------------------------------------------------- 2) Label sub-component ---------------------------------------------------------------------------*/ interface NumericMetaLabelProps { showValue?: boolean; // optionally show the current numeric value(s) className?: string; children: React.ReactNode; } function NumericMetaLabel({ children, showValue, className }: NumericMetaLabelProps) { const ctx = useContext(NumericMetaContext); if (!ctx) { throw new Error('NumericMetaLabel must be used inside .'); } const { mode, singleValue, doubleValue } = ctx; let displayedValue = ''; let valueClasses = ''; if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') { displayedValue = singleValue.toString(); valueClasses = 'w-10'; } else if (mode === 'doubleRange') { displayedValue = `[${doubleValue[0]} - ${doubleValue[1]}]`; } return (
{children} {showValue && ( {`: ${displayedValue}`} )}
); } /* ------------------------------------------------------------------------- 3) SingleRange sub-component ---------------------------------------------------------------------------*/ interface SingleRangeProps { showNumberInput?: boolean; sliderClassName?: string; numberInputClassName?: string; } function SingleRange({ showNumberInput, sliderClassName, numberInputClassName }: SingleRangeProps) { const ctx = useContext(NumericMetaContext); if (!ctx) { throw new Error('SingleRange must be used inside .'); } const { mode, singleValue, setSingleValue, min, max, step } = ctx; const handleSliderChange = useCallback( (val: number[]) => { setSingleValue(val[0]); }, [setSingleValue] ); const handleNumberChange = useCallback( (evt: React.ChangeEvent) => { const parsed = parseFloat(evt.target.value); if (!isNaN(parsed)) { setSingleValue(Math.max(min, Math.min(parsed, max))); } }, [min, max, setSingleValue] ); if (mode !== 'singleRange') { return null; } return (
{showNumberInput && ( )}
); } /* ------------------------------------------------------------------------- 4) DoubleRange sub-component ---------------------------------------------------------------------------*/ interface DoubleRangeProps { showNumberInputs?: boolean; className?: string; } function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) { const ctx = useContext(NumericMetaContext); if (!ctx) { throw new Error('DoubleRange must be used inside .'); } const { mode, doubleValue, setDoubleValue, min, max, step } = ctx; const handleSliderChange = useCallback( (values: [number, number]) => { setDoubleValue(values); }, [setDoubleValue] ); if (mode !== 'doubleRange') { return null; } return (
); } /* ------------------------------------------------------------------------- 5) Basic NumberInput sub-component ---------------------------------------------------------------------------*/ interface NumberInputProps { className?: string; } function NumberInput({ className }: NumberInputProps) { const ctx = useContext(NumericMetaContext); if (!ctx) { throw new Error('NumberInput must be used inside .'); } const { mode, singleValue, setSingleValue, min, max, step } = ctx; if (mode !== 'number') { return null; } const handleChange = (evt: React.ChangeEvent) => { const val = parseFloat(evt.target.value); if (!isNaN(val)) { setSingleValue(Math.max(min, Math.min(val, max))); } }; // Calculate width based on max value's length, with a minimum of 3 characters const maxLength = Math.max(3, max?.toString().length ?? 3); const calculatedWidth = `${maxLength + 1.5}ch`; return ( ); } /* ------------------------------------------------------------------------- 6) NumberStepper sub-component ---------------------------------------------------------------------------*/ interface NumberStepperProps { className?: string; children?: React.ReactNode; direction?: 'horizontal' | 'vertical'; } // Modified NumberStepper component to properly position left/right controls function NumberStepper({ className, children, direction }: NumberStepperProps) { const ctx = useContext(NumericMetaContext); if (!ctx) { throw new Error('NumberStepper must be used inside .'); } const { mode, singleValue, setSingleValue, min, max, step } = ctx; if (mode !== 'stepper') { return null; } // Calculate decimal places based on step const decimalPlaces = getDecimalPlaces(step); // Format displayed value with proper decimal places const displayValue = React.useMemo(() => { return decimalPlaces > 0 ? singleValue.toFixed(decimalPlaces) : singleValue.toString(); }, [singleValue, decimalPlaces]); const handleInputChange = (evt: React.ChangeEvent) => { const val = evt.target.value; // Allow empty string, minus sign, or decimal point for flexibility if (val === '' || val === '-' || val === '.') { return; } const numValue = Number(val); if (!isNaN(numValue)) { setSingleValue(Math.max(min, Math.min(numValue, max))); } }; const handleBlur = () => { // Ensure value is within constraints when input loses focus const boundedValue = Math.max(min, Math.min(singleValue, max)); if (boundedValue !== singleValue) { setSingleValue(boundedValue); } }; // Check if children is HorizontalControls component const hasHorizontalControls = direction === 'horizontal'; if (hasHorizontalControls) { // We'll handle the control positioning ourselves return (
{children}
); } return (
); } // New components for left and right controls function LeftControl({ min, step, value, setValue }) { const decrement = useCallback(() => { const newValue = Math.max(value - step, min); setValue(newValue); }, [value, min, step, setValue]); return ( ); } function RightControl({ max, step, value, setValue }) { const increment = useCallback(() => { const newValue = Math.min(value + step, max); setValue(newValue); }, [value, max, step, setValue]); return ( ); } export const Numeric = { Container: NumericMetaContainer, Label: NumericMetaLabel, SingleRange, DoubleRange, NumberInput, NumberStepper, }; export default Numeric;