Files
malenia-frontend/src/components/DeviceSlider.tsx
2026-08-10 12:01:03 +07:00

166 lines
5.6 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 { useCallback, useEffect, useRef, useState } from 'react';
interface DeviceSliderProps {
min: number;
max: number;
value: number;
onChange: (v: number) => void;
}
const pctOf = (v: number, min: number, max: number) => (v - min) / (max - min);
export function DeviceSlider({ min, max, value, onChange }: DeviceSliderProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const frameRef = useRef<HTMLDivElement | null>(null);
const hitRef = useRef<HTMLDivElement | null>(null);
const pointerRef = useRef<HTMLDivElement | null>(null);
const haloRef = useRef<HTMLDivElement | null>(null);
const fillRef = useRef<HTMLDivElement | null>(null);
const [dragging, setDragging] = useState(false);
const renderPositions = useCallback(() => {
const frame = frameRef.current;
if (!frame) return;
const innerW = Math.max(0, frame.clientWidth - 8);
const x = 4 + pctOf(value, min, max) * innerW;
if (pointerRef.current) pointerRef.current.style.left = `${x}px`;
if (haloRef.current) haloRef.current.style.left = `${x}px`;
if (fillRef.current) fillRef.current.style.width = `${x - 4}px`;
}, [max, min, value]);
useEffect(() => {
renderPositions();
}, [renderPositions]);
useEffect(() => {
window.addEventListener('resize', renderPositions);
return () => window.removeEventListener('resize', renderPositions);
}, [renderPositions]);
const valueFromClientX = useCallback(
(clientX: number) => {
const frame = frameRef.current;
if (!frame) return value;
const rect = frame.getBoundingClientRect();
const innerLeft = rect.left + 4;
const innerW = rect.width - 8;
const p = (clientX - innerLeft) / innerW;
const clamped = Math.max(0, Math.min(1, p));
return Math.round(min + clamped * (max - min));
},
[max, min, value],
);
useEffect(() => {
const hit = hitRef.current;
if (!hit) return;
const onDown = (e: PointerEvent) => {
e.preventDefault();
try {
hit.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
setDragging(true);
onChange(valueFromClientX(e.clientX));
};
const onMove = (e: PointerEvent) => {
if (!dragging) return;
onChange(valueFromClientX(e.clientX));
};
const onUp = (e: PointerEvent) => {
if (!dragging) return;
setDragging(false);
try {
hit.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
hit.addEventListener('pointerdown', onDown);
hit.addEventListener('pointermove', onMove);
hit.addEventListener('pointerup', onUp);
hit.addEventListener('pointercancel', onUp);
return () => {
hit.removeEventListener('pointerdown', onDown);
hit.removeEventListener('pointermove', onMove);
hit.removeEventListener('pointerup', onUp);
hit.removeEventListener('pointercancel', onUp);
};
}, [dragging, onChange, valueFromClientX]);
const onFrameClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (dragging) return;
if (e.target instanceof Element && e.target.closest('.pointer')) return;
onChange(valueFromClientX(e.clientX));
};
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
let next = value;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = Math.min(max, value + (e.shiftKey ? 2 : 1));
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = Math.max(min, value - (e.shiftKey ? 2 : 1));
else if (e.key === 'Home') next = min;
else if (e.key === 'End') next = max;
else return;
e.preventDefault();
onChange(next);
};
const ticks: number[] = [];
for (let i = min; i <= max; i++) ticks.push(i);
const mid = Math.round((min + max) / 2);
return (
<div className={`slider-wrap ${dragging ? 'is-dragging' : ''}`.trim()} ref={wrapRef} data-cursor="">
<div className="slider-axis">
<span>Устройства</span>
<span>
<span className="axis-now">{value}</span> &nbsp;мест
</span>
</div>
<div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}>
<div className="slider-track">
<div className="slider-fill" ref={fillRef} />
</div>
<div className="slider-ticks" aria-hidden="true">
{ticks.map((i) => (
<div
key={i}
className={`tick ${i === min || i === max || i === mid ? 'is-major' : ''} ${i === value ? 'is-active' : ''}`.trim()}
>
<span className="tick-label">{i}</span>
</div>
))}
</div>
<div className="pointer-halo" ref={haloRef} aria-hidden="true" />
<div
className="pointer"
ref={pointerRef}
role="slider"
tabIndex={0}
aria-label="Количество устройств"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${value} устройств`}
onKeyDown={onKey}
onFocus={() => setDragging(true)}
onBlur={() => setDragging(false)}
/>
<div className="slider-hit" ref={hitRef} />
</div>
<div className="slider-caption">
<span className="hint">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 12h8M12 8l-4 4 4 4M12 8l4 4-4 4" />
</svg>
Тяните ромб · стрелки для точной настройки
</span>
<span>Диапазон {min} – {max}</span>
</div>
</div>
);
}