166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
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> мест
|
||
</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>
|
||
);
|
||
}
|