v0.0.01b
This commit is contained in:
165
src/components/DeviceSlider.tsx
Normal file
165
src/components/DeviceSlider.tsx
Normal 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> мест
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user