feat: add demo mode
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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<boolean>;
|
||||
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<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const keyboardValue = useRef<number | null>(null);
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const dragValue = useRef(value);
|
||||
const dragging = useRef(false);
|
||||
const thumbRef = useRef<HTMLDivElement>(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<HTMLDivElement>) {
|
||||
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<HTMLDivElement>) {
|
||||
const target = e.currentTarget;
|
||||
dragging.current = true;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
valueFromPointer(e.clientY);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!dragging.current) return;
|
||||
valueFromPointer(e.clientY);
|
||||
}
|
||||
|
||||
async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!dragging.current) return;
|
||||
|
||||
const finalValue = dragValue.current;
|
||||
const success = await onChange?.(finalValue);
|
||||
|
||||
dragging.current = false;
|
||||
|
||||
if (success === false) {
|
||||
setDisplayValue(value);
|
||||
dragValue.current = value;
|
||||
onInput?.(value);
|
||||
}
|
||||
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.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 (
|
||||
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
||||
<div
|
||||
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
|
||||
ref={trackRef}
|
||||
>
|
||||
{showTicks && (
|
||||
<div className="ticks left">
|
||||
{Array.from({ length: tickCount }).map((_, i) => (
|
||||
<span key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="slider"
|
||||
ref={sliderRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<div className="track" />
|
||||
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{showTicks && (
|
||||
<div className="ticks right">
|
||||
{Array.from({ length: tickCount }).map((_, i) => (
|
||||
<span key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={['value', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={editingText ?? formattedValue}
|
||||
style={{ width: '100%' }}
|
||||
onFocus={() => 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 && <span className="value-unit">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user