This commit is contained in:
2026-08-10 12:01:03 +07:00
commit 265b1202ad
95 changed files with 27718 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
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>
);
}