feat: responsive connection section

This commit is contained in:
2026-07-24 22:25:50 +02:00
parent 62d3b22fea
commit a157b5f198
10 changed files with 245 additions and 183 deletions
@@ -0,0 +1,259 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import './VerticalSlider.css';
// Design size 260*70 (height*width) — kept as the reference ratio.
// All CSS dimensions are expressed in container-query width units (cqw)
// relative to this DESIGN_WIDTH, so the whole component scales together
// and preserves this aspect ratio at any rendered size.
const DESIGN_WIDTH = 70;
type VerticalSliderProps = {
min: number;
max: number;
step: number;
switchStepValue?: number;
switchStep?: number;
value?: number;
onChange?: (value: number) => void;
unit?: string;
className?: string;
style?: React.CSSProperties;
};
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,
switchStepValue,
switchStep,
value: controlledValue,
onChange,
unit = '',
className,
style,
}: VerticalSliderProps) {
const [internalValue, setInternalValue] = useState(min);
const value = controlledValue ?? internalValue;
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const lastValue = useRef(value);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const rafRef = useRef<number | null>(null);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
useEffect(() => {
if (keyboardEditing.current) return;
setDisplayValue(value);
dragValue.current = value;
}, [value]);
const currentStep =
switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue
? switchStep
: step;
const decimals = useMemo(
() => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0),
[step, switchStep]
);
const maxChars = useMemo(() => {
const minStr = min.toFixed(decimals);
const maxStr = max.toFixed(decimals);
return Math.max(minStr.length, maxStr.length) + unit.length;
}, [min, max, decimals, unit]);
// Computed in "design pixels" (i.e. assuming the component is rendered at
// DESIGN_WIDTH px wide), then converted below to cqw so it scales with
// the actual rendered size instead of staying a fixed pixel value.
const valueFontSize = useMemo(() => {
const availableWidth = DESIGN_WIDTH - 8;
const AVG_CHAR_WIDTH_RATIO = 0.6;
const sizeByWidth = availableWidth / (maxChars * AVG_CHAR_WIDTH_RATIO);
return Math.min(16, Math.max(8, sizeByWidth));
}, [maxChars]);
// Convert the "design px" font size into cqw units relative to the
// component's own width, so it scales proportionally with everything else.
const valueFontSizeCqw = useMemo(() => (valueFontSize / DESIGN_WIDTH) * 100, [valueFontSize]);
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
const snapped = Math.round(clamped / currentStep) * currentStep;
return Number(snapped.toFixed(6));
}
function updateValue(v: number) {
const next = normalizeValue(v);
dragValue.current = next;
lastValue.current = next;
if (thumbRef.current) {
const percent = (next - min) / (max - min);
thumbRef.current.style.bottom = `${percent * 100}%`;
}
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
setDisplayValue(dragValue.current);
rafRef.current = null;
});
}
}
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;
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); // updates displayValue only
}
function handleKeyUp() {
if (keyboardValue.current == null) return;
keyboardEditing.current = false;
const next = keyboardValue.current;
if (controlledValue === undefined) {
setInternalValue(next);
}
onChange?.(next);
keyboardValue.current = null;
}
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);
}
function endDrag(e: React.PointerEvent<HTMLDivElement>) {
dragging.current = false;
const finalValue = dragValue.current;
if (controlledValue === undefined) setInternalValue(finalValue);
setDisplayValue(finalValue);
onChange?.(finalValue);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
function commitEditing() {
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
if (!Number.isNaN(parsed)) updateValue(parsed);
}
setEditingText(null);
}
const percent = (value - min) / (max - min);
return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-container">
<div className="gauge-track">
<div className="ticks left">
{Array.from({ length: 15 }).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>
<div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
</div>
<div className="value" style={{ fontSize: `${valueFontSizeCqw}cqw` }}>
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={editingText ?? displayValue.toFixed(decimals)}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))}
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();
}
}}
/>
<span className="value-unit">{unit}</span>
</div>
</div>
</div>
);
}