'use client'; import React, { useCallback, useRef, useState, useEffect } from 'react'; interface RangeFieldProps { label: string; value: number; min: number; max: number; step?: number; onChange: (value: number) => void; helperText?: string; error?: string | undefined; disabled?: boolean; unitLabel?: string; rangeHint?: string; /** Show large value display above slider */ showLargeValue?: boolean; /** Compact mode - single line */ compact?: boolean; } export default function RangeField({ label, value, min, max, step = 1, onChange, helperText, error, disabled, unitLabel = 'min', rangeHint, showLargeValue = false, compact = false, }: RangeFieldProps) { const [localValue, setLocalValue] = useState(value); const [isDragging, setIsDragging] = useState(false); const sliderRef = useRef(null); const inputRef = useRef(null); // Sync local value with prop when not dragging useEffect(() => { if (!isDragging) { setLocalValue(value); } }, [value, isDragging]); const clampedValue = Number.isFinite(localValue) ? Math.min(Math.max(localValue, min), max) : min; const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100; const effectiveRangeHint = rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`); const calculateValueFromPosition = useCallback( (clientX: number) => { if (!sliderRef.current) return clampedValue; const rect = sliderRef.current.getBoundingClientRect(); const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1); const rawValue = min + percent * (max - min); const steppedValue = Math.round(rawValue / step) * step; return Math.min(Math.max(steppedValue, min), max); }, [min, max, step, clampedValue] ); const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (disabled) return; e.preventDefault(); setIsDragging(true); const newValue = calculateValueFromPosition(e.clientX); setLocalValue(newValue); onChange(newValue); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, [disabled, calculateValueFromPosition, onChange] ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (!isDragging || disabled) return; const newValue = calculateValueFromPosition(e.clientX); setLocalValue(newValue); onChange(newValue); }, [isDragging, disabled, calculateValueFromPosition, onChange] ); const handlePointerUp = useCallback(() => { setIsDragging(false); }, []); const handleInputChange = (e: React.ChangeEvent) => { const raw = e.target.value; if (raw === '') { setLocalValue(min); return; } const parsed = parseInt(raw, 10); if (!Number.isNaN(parsed)) { const clamped = Math.min(Math.max(parsed, min), max); setLocalValue(clamped); onChange(clamped); } }; const handleInputBlur = () => { // Ensure value is synced on blur onChange(clampedValue); }; // Quick preset buttons for common values const quickPresets = [ Math.round(min + (max - min) * 0.25), Math.round(min + (max - min) * 0.5), Math.round(min + (max - min) * 0.75), ].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue); if (compact) { return (
{/* Track background */}
{/* Track fill */}
{/* Thumb */}
{clampedValue} {unitLabel}
{error &&

{error}

}
); } return (
{effectiveRangeHint}
{showLargeValue && (
{clampedValue} {unitLabel}
)} {/* Custom slider */}
{/* Track background */}
{/* Track fill with gradient */}
{/* Tick marks */}
{[0, 25, 50, 75, 100].map((tick) => (
= tick ? 'bg-white/40' : 'bg-charcoal-outline' }`} /> ))}
{/* Thumb */}
{/* Value input and quick presets */}
{unitLabel}
{quickPresets.length > 0 && (
{quickPresets.slice(0, 3).map((preset) => ( ))}
)}
{helperText &&

{helperText}

} {error &&

{error}

}
); }