import { useEffect, useMemo, useRef, useState } from 'react'; import './VerticalSlider.css'; type StepFn = (value: number) => number; type VerticalSliderProps = { min: number; max: number; step?: StepFn; value: number; onChange?: (value: number) => Promise; onInput?: (value: number) => void; unit?: string; formatValue?: (value: number, decimals: number) => string; showTicks?: boolean; className?: string; style?: React.CSSProperties; }; const defaultStep: StepFn = () => 0.1; function formatValueDefault(v: number, decimals: number) { return v.toFixed(decimals); } const MIN_TICKS = 3; const MAX_TICK_SPACING_PX = 16; function getDecimals(n: number) { const s = n.toString(); const i = s.indexOf('.'); return i === -1 ? 0 : s.length - i - 1; } export default function VerticalSlider({ min, max, step = defaultStep, value, onChange, onInput, unit = '', formatValue, showTicks = true, className, style, }: VerticalSliderProps) { const [editingText, setEditingText] = useState(null); const inputRef = useRef(null); const sliderRef = useRef(null); const trackRef = useRef(null); const keyboardValue = useRef(null); const [displayValue, setDisplayValue] = useState(value); const dragValue = useRef(value); const dragging = useRef(false); const thumbRef = useRef(null); const keyboardEditing = useRef(false); const [tickCount, setTickCount] = useState(MIN_TICKS); useEffect(() => { if (dragging.current || keyboardEditing.current || editingText !== null) { return; } setDisplayValue(value); dragValue.current = value; }, [value]); useEffect(() => { const el = trackRef.current; if (!el || typeof ResizeObserver === 'undefined') return; const recompute = () => { const h = el.clientHeight; if (!h) return; const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1); setTickCount(n); }; recompute(); const observer = new ResizeObserver(recompute); observer.observe(el); return () => observer.disconnect(); }, []); const decimals = useMemo( () => Math.min(3, getDecimals(step(displayValue))), [step, displayValue] ); const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals); function normalizeValue(input: number) { const clamped = Math.min(max, Math.max(min, input)); const currentStep = step(clamped); const snapped = min + Math.round((clamped - min) / currentStep) * currentStep; return Number(Math.min(max, Math.max(min, snapped)).toFixed(6)); } function updateValue(input: number) { const next = normalizeValue(input); dragValue.current = next; setDisplayValue(next); onInput?.(next); } function handleKeyDown(e: React.KeyboardEvent) { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { return; } keyboardEditing.current = true; e.preventDefault(); const base = keyboardValue.current ?? value; const currentStep = step(base); let next = base; if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep; if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep; next = normalizeValue(next); keyboardValue.current = next; updateValue(next); } async function handleKeyUp() { if (keyboardValue.current == null) return; const next = keyboardValue.current; const success = await onChange?.(next); keyboardEditing.current = false; keyboardValue.current = null; if (success === false) { setDisplayValue(value); dragValue.current = value; onInput?.(value); } } function valueFromPointer(clientY: number) { if (!sliderRef.current) return; const rect = sliderRef.current.getBoundingClientRect(); const percent = 1 - (clientY - rect.top) / rect.height; const raw = min + percent * (max - min); updateValue(raw); } function startDrag(e: React.PointerEvent) { const target = e.currentTarget; dragging.current = true; target.setPointerCapture(e.pointerId); valueFromPointer(e.clientY); } function handlePointerMove(e: React.PointerEvent) { if (!dragging.current) return; valueFromPointer(e.clientY); } async function endDrag(e: React.PointerEvent) { if (!dragging.current) return; const target = e.currentTarget; const pointerId = e.pointerId; const finalValue = dragValue.current; const success = await onChange?.(finalValue); dragging.current = false; if (success === false) { setDisplayValue(value); dragValue.current = value; onInput?.(value); } if (target.hasPointerCapture(pointerId)) { target.releasePointerCapture(pointerId); } } async function commitEditing() { if (editingText !== null && editingText !== '' && editingText !== '-') { const parsed = Number(editingText); if (!Number.isNaN(parsed)) { const next = normalizeValue(parsed); setDisplayValue(next); onInput?.(next); const success = await onChange?.(next); if (success === false) { setDisplayValue(value); dragValue.current = value; onInput?.(value); } } } setEditingText(null); } const percent = (displayValue - min) / (max - min); return (
{showTicks && (
{Array.from({ length: tickCount }).map((_, i) => ( ))}
)}
{showTicks && (
{Array.from({ length: tickCount }).map((_, i) => ( ))}
)}
setEditingText(value.toFixed(decimals))} // plain number while editing onChange={(e) => setEditingText(e.target.value)} onBlur={commitEditing} onKeyDown={(e) => { if (e.key === 'Enter') inputRef.current?.blur(); if (e.key === 'Escape') { setEditingText(null); inputRef.current?.blur(); } }} /> {unit && {unit}}
); }