/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { Minus, Plus } from 'lucide-react' import { useState, useEffect, useRef } from 'react' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' interface NumericSpinnerInputProps { value: number | null | undefined onChange: (value: number) => void min?: number max?: number step?: number disabled?: boolean className?: string label?: string } export function NumericSpinnerInput({ value, onChange, min = 0, max, step = 1, disabled = false, className, label, }: NumericSpinnerInputProps) { const [localValue, setLocalValue] = useState(String(value ?? 0)) const [editing, setEditing] = useState(false) const inputRef = useRef(null) useEffect(() => { if (!editing) { // eslint-disable-next-line react-hooks/set-state-in-effect setLocalValue(String(value ?? 0)) } }, [value, editing]) const clamp = (v: number) => { let result = v if (min !== undefined) result = Math.max(min, result) if (max !== undefined) result = Math.min(max, result) return result } const handleIncrement = (e: React.MouseEvent) => { e.stopPropagation() if (disabled) return const next = clamp((Number(localValue) || 0) + step) setLocalValue(String(next)) onChange(next) } const handleDecrement = (e: React.MouseEvent) => { e.stopPropagation() if (disabled) return const next = clamp((Number(localValue) || 0) - step) setLocalValue(String(next)) onChange(next) } const handleStartEdit = () => { if (disabled) return setEditing(true) requestAnimationFrame(() => inputRef.current?.select()) } const handleInputChange = (e: React.ChangeEvent) => { const raw = e.target.value if (raw === '' || raw === '-') { setLocalValue(raw) return } if (!/^-?\d+$/.test(raw)) return setLocalValue(raw) } const commitValue = () => { setEditing(false) const num = Number(localValue) if (isNaN(num) || localValue === '' || localValue === '-') { setLocalValue(String(value ?? 0)) return } const clamped = clamp(num) setLocalValue(String(clamped)) if (clamped !== (value ?? 0)) { onChange(clamped) } } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() commitValue() } else if (e.key === 'Escape') { setEditing(false) setLocalValue(String(value ?? 0)) } } const atMin = min !== undefined && Number(localValue) <= min const atMax = max !== undefined && Number(localValue) >= max return (
{label && ( )}
{editing ? ( ) : ( )}
) }