/* eslint-disable gridpilot-rules/no-raw-html-in-app */ import React, { useCallback, useEffect, useRef, useState } 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 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 ( {label} {/* Track background */} {/* Track fill */} {/* Thumb */} {clampedValue} {unitLabel} {error && {error}} ); } return ( {label} {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) => ( { setLocalValue(preset); onChange(preset); }} disabled={disabled} className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors" > {preset} ))} )} {helperText && {helperText}} {error && {error}} ); }
{error}
{helperText}