/*
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 (