Files
gridpilot.gg/apps/website/components/shared/RangeField.tsx
2026-01-19 01:24:07 +01:00

334 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Stack gap={1.5}>
<Group justify="between" gap={3}>
<Text as="label" size="xs" weight="medium" variant="low">{label}</Text>
<Box display="flex" alignItems="center" gap={2} flex={1} maxWidth="200px">
<div
ref={sliderRef}
style={{
position: 'relative',
flex: 1,
height: '1.5rem',
cursor: disabled ? 'not-allowed' : 'pointer',
touchAction: 'none',
opacity: disabled ? 0.5 : 1
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<Box position="absolute" top="50%" left={0} right={0} height="0.375rem" rounded="full" bg="var(--ui-color-border-muted)" style={{ transform: 'translateY(-50%)' }} />
{/* Track fill */}
<Box
position="absolute"
top="50%"
left={0}
height="0.375rem"
rounded="full"
bg="var(--ui-color-intent-primary)"
style={{ width: `${rangePercent}%`, transform: 'translateY(-50%)', transition: 'width 75ms' }}
/>
{/* Thumb */}
<Box
position="absolute"
top="50%"
width="1rem"
height="1rem"
rounded="full"
bg="white"
border="2px solid var(--ui-color-intent-primary)"
shadow="md"
style={{
left: `${rangePercent}%`,
transform: `translate(-50%, -50%) ${isDragging ? 'scale(1.25)' : ''}`,
transition: 'transform 75ms, left 75ms',
boxShadow: isDragging ? '0 0 12px rgba(25, 140, 255, 0.5)' : undefined
}}
/>
</div>
<Box flexShrink={0}>
<Group gap={1}>
<Text size="sm" weight="semibold" variant="high" textAlign="right" width="2rem">{clampedValue}</Text>
<Text size="xs" variant="low" style={{ fontSize: '10px' }}>{unitLabel}</Text>
</Group>
</Box>
</Box>
</Group>
{error && <Text size="xs" variant="critical" style={{ fontSize: '10px' }}>{error}</Text>}
</Stack>
);
}
return (
<Stack gap={3}>
<Group justify="between" align="baseline" gap={2}>
<Text as="label" size="sm" weight="medium" variant="med">{label}</Text>
<Text size="xs" variant="low" style={{ fontSize: '10px' }}>{effectiveRangeHint}</Text>
</Group>
{showLargeValue && (
<Group align="baseline" gap={1}>
<Text size="3xl" weight="bold" variant="high" font="mono">{clampedValue}</Text>
<Text size="sm" variant="low">{unitLabel}</Text>
</Group>
)}
{/* Custom slider */}
<div
ref={sliderRef}
style={{
position: 'relative',
height: '2rem',
cursor: disabled ? 'not-allowed' : 'pointer',
touchAction: 'none',
userSelect: 'none',
opacity: disabled ? 0.5 : 1
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<Box position="absolute" top="50%" left={0} right={0} height="0.5rem" rounded="full" bg="var(--ui-color-border-muted)" style={{ transform: 'translateY(-50%)', opacity: 0.8 }} />
{/* Track fill with gradient */}
<Box
position="absolute"
top="50%"
left={0}
height="0.5rem"
rounded="full"
style={{
width: `${rangePercent}%`,
transform: 'translateY(-50%)',
transition: 'width 75ms',
background: 'linear-gradient(to right, var(--ui-color-intent-primary), #00f2ff)'
}}
/>
{/* Tick marks */}
<Box position="absolute" top="50%" left={0} right={0} display="flex" justifyContent="between" paddingX={1} style={{ transform: 'translateY(-50%)' }}>
{[0, 25, 50, 75, 100].map((tick) => (
<Box
key={tick}
width="0.125rem"
height="0.25rem"
rounded="full"
bg={rangePercent >= tick ? 'rgba(255, 255, 255, 0.4)' : 'var(--ui-color-border-muted)'}
style={{ transition: 'background-color 75ms' }}
/>
))}
</Box>
{/* Thumb */}
<Box
position="absolute"
top="50%"
width="1.25rem"
height="1.25rem"
rounded="full"
bg="white"
border="2px solid var(--ui-color-intent-primary)"
shadow="lg"
style={{
left: `${rangePercent}%`,
transform: `translate(-50%, -50%) ${isDragging ? 'scale(1.25)' : ''}`,
transition: 'transform 75ms, left 75ms',
boxShadow: isDragging ? '0 0 16px rgba(25, 140, 255, 0.6)' : '0 2px 8px rgba(0,0,0,0.3)'
}}
/>
</div>
{/* Value input and quick presets */}
<Group justify="between" align="center" gap={3}>
<Group gap={2}>
<input
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={clampedValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
disabled={disabled}
style={{
width: '4rem',
padding: '0.375rem 0.5rem',
fontSize: '0.875rem',
fontWeight: 500,
textAlign: 'center',
borderRadius: 'var(--ui-radius-lg)',
backgroundColor: 'var(--ui-color-bg-surface-muted)',
border: `1px solid ${error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)'}`,
color: 'white',
outline: 'none',
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'text'
}}
/>
<Text size="xs" variant="low">{unitLabel}</Text>
</Group>
{quickPresets.length > 0 && (
<Group gap={1}>
{quickPresets.slice(0, 3).map((preset) => (
<button
key={preset}
type="button"
onClick={() => {
setLocalValue(preset);
onChange(preset);
}}
disabled={disabled}
style={{
padding: '0.25rem 0.5rem',
fontSize: '10px',
borderRadius: 'var(--ui-radius-sm)',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
color: 'var(--ui-color-text-low)',
border: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 150ms'
}}
>
{preset}
</button>
))}
</Group>
)}
</Group>
{helperText && <Text size="xs" variant="low">{helperText}</Text>}
{error && <Text size="xs" variant="critical">{error}</Text>}
</Stack>
);
}