130 lines
3.3 KiB
TypeScript
130 lines
3.3 KiB
TypeScript
'use client';
|
||
|
||
import Input from '@/components/ui/Input';
|
||
|
||
interface RangeFieldProps {
|
||
label: string;
|
||
value: number;
|
||
min: number;
|
||
max: number;
|
||
step?: number;
|
||
onChange: (value: number) => void;
|
||
helperText?: string;
|
||
error?: string;
|
||
disabled?: boolean;
|
||
/**
|
||
* Optional unit label, defaults to "min".
|
||
*/
|
||
unitLabel?: string;
|
||
/**
|
||
* Optional override for the right-hand range hint.
|
||
*/
|
||
rangeHint?: string;
|
||
}
|
||
|
||
export default function RangeField({
|
||
label,
|
||
value,
|
||
min,
|
||
max,
|
||
step = 1,
|
||
onChange,
|
||
helperText,
|
||
error,
|
||
disabled,
|
||
unitLabel = 'min',
|
||
rangeHint,
|
||
}: RangeFieldProps) {
|
||
const clampedValue = Number.isFinite(value)
|
||
? Math.min(Math.max(value, min), max)
|
||
: min;
|
||
|
||
const handleSliderChange = (raw: string) => {
|
||
const parsed = parseInt(raw, 10);
|
||
if (Number.isNaN(parsed)) {
|
||
return;
|
||
}
|
||
const next = Math.min(Math.max(parsed, min), max);
|
||
onChange(next);
|
||
};
|
||
|
||
const handleNumberChange = (raw: string) => {
|
||
if (raw.trim() === '') {
|
||
// Allow the field to clear without jumping the slider;
|
||
// keep the previous value until the user types a number.
|
||
return;
|
||
}
|
||
const parsed = parseInt(raw, 10);
|
||
if (Number.isNaN(parsed)) {
|
||
return;
|
||
}
|
||
const next = Math.min(Math.max(parsed, min), max);
|
||
onChange(next);
|
||
};
|
||
|
||
const rangePercent =
|
||
((clampedValue - min) / Math.max(max - min, 1)) * 100;
|
||
|
||
const effectiveRangeHint =
|
||
rangeHint ??
|
||
(min === 0
|
||
? `Up to ${max} ${unitLabel}`
|
||
: `${min}–${max} ${unitLabel}`);
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="flex items-baseline justify-between gap-2">
|
||
<label className="block text-sm font-medium text-gray-300">
|
||
{label}
|
||
</label>
|
||
<p className="text-[11px] text-gray-500">
|
||
{effectiveRangeHint}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="relative">
|
||
<div className="pointer-events-none absolute inset-y-0 left-0 right-0 rounded-full bg-charcoal-outline/60" />
|
||
<div
|
||
className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-primary-blue"
|
||
style={{ width: `${rangePercent}%` }}
|
||
/>
|
||
<input
|
||
type="range"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={clampedValue}
|
||
onChange={(e) => handleSliderChange(e.target.value)}
|
||
disabled={disabled}
|
||
className="relative z-10 h-2 w-full appearance-none bg-transparent focus:outline-none accent-primary-blue"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<div className="max-w-[96px]">
|
||
<Input
|
||
type="number"
|
||
value={Number.isFinite(value) ? String(clampedValue) : ''}
|
||
onChange={(e) => handleNumberChange(e.target.value)}
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
disabled={disabled}
|
||
className="px-3 py-2 text-sm"
|
||
error={!!error}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-gray-400">{unitLabel}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{helperText && (
|
||
<p className="text-xs text-gray-500">{helperText}</p>
|
||
)}
|
||
{error && (
|
||
<p className="text-xs text-warning-amber mt-1">{error}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
} |