231 lines
6.1 KiB
JavaScript
231 lines
6.1 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import "./Slider.css";
|
||
|
||
function cx(...parts) {
|
||
return parts.filter(Boolean).join(" ");
|
||
}
|
||
|
||
function clamp(n, min, max) {
|
||
return Math.min(max, Math.max(min, n));
|
||
}
|
||
|
||
/**
|
||
* @param {number} clientX
|
||
* @param {DOMRect} rect
|
||
* @param {number} min
|
||
* @param {number} max
|
||
* @param {number} step
|
||
*/
|
||
function valueFromPointer(clientX, rect, min, max, step) {
|
||
if (rect.width <= 0) return min;
|
||
const t = (clientX - rect.left) / rect.width;
|
||
const raw = min + t * (max - min);
|
||
if (step > 0) {
|
||
const steps = Math.round((raw - min) / step);
|
||
return clamp(min + steps * step, min, max);
|
||
}
|
||
return clamp(raw, min, max);
|
||
}
|
||
|
||
/**
|
||
* Ant Design–like single-value horizontal slider (no antd/rc-slider).
|
||
*
|
||
* @param {object} props
|
||
* @param {number} [props.min=0]
|
||
* @param {number} [props.max=100]
|
||
* @param {number} props.value
|
||
* @param {(n: number) => void} props.onChange
|
||
* @param {number} [props.step=1]
|
||
* @param {boolean} [props.disabled]
|
||
* @param {string} [props.className]
|
||
* @param {string} [props.id] — forwarded to the handle for label association
|
||
* @param {string} [props['aria-labelledby']]
|
||
* @param {string} [props['aria-label']]
|
||
*/
|
||
export function Slider({
|
||
min = 0,
|
||
max = 100,
|
||
value,
|
||
onChange,
|
||
step = 1,
|
||
disabled = false,
|
||
className,
|
||
id,
|
||
"aria-labelledby": ariaLabelledBy,
|
||
"aria-label": ariaLabel,
|
||
}) {
|
||
const wrapRef = useRef(null);
|
||
const draggingRef = useRef(false);
|
||
const [focused, setFocused] = useState(false);
|
||
|
||
const lo = Math.min(min, max);
|
||
const hi = Math.max(min, max);
|
||
const safeStep = step > 0 ? step : 1;
|
||
|
||
const clamped = useMemo(() => {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return lo;
|
||
if (safeStep > 0) {
|
||
const steps = Math.round((n - lo) / safeStep);
|
||
return clamp(lo + steps * safeStep, lo, hi);
|
||
}
|
||
return clamp(n, lo, hi);
|
||
}, [value, lo, hi, safeStep]);
|
||
|
||
const percent = hi === lo ? 0 : ((clamped - lo) / (hi - lo)) * 100;
|
||
|
||
const emitFromClientX = useCallback(
|
||
(clientX) => {
|
||
const el = wrapRef.current;
|
||
if (!el || disabled) return;
|
||
const rect = el.getBoundingClientRect();
|
||
const next = valueFromPointer(clientX, rect, lo, hi, safeStep);
|
||
if (next !== clamped) onChange(next);
|
||
},
|
||
[clamped, disabled, hi, lo, onChange, safeStep],
|
||
);
|
||
|
||
const onRailPointerDown = useCallback(
|
||
(e) => {
|
||
if (disabled || e.button !== 0) return;
|
||
e.preventDefault();
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
draggingRef.current = true;
|
||
emitFromClientX(e.clientX);
|
||
},
|
||
[disabled, emitFromClientX],
|
||
);
|
||
|
||
const onRailPointerMove = useCallback(
|
||
(e) => {
|
||
if (!draggingRef.current || disabled) return;
|
||
emitFromClientX(e.clientX);
|
||
},
|
||
[disabled, emitFromClientX],
|
||
);
|
||
|
||
const onRailPointerUp = useCallback((e) => {
|
||
if (draggingRef.current) {
|
||
try {
|
||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
draggingRef.current = false;
|
||
}, []);
|
||
|
||
const onHandlePointerDown = useCallback(
|
||
(e) => {
|
||
e.stopPropagation();
|
||
if (disabled || e.button !== 0) return;
|
||
e.preventDefault();
|
||
const rail = wrapRef.current?.querySelector(".slider__rail-hit");
|
||
if (rail) {
|
||
rail.setPointerCapture(e.pointerId);
|
||
draggingRef.current = true;
|
||
}
|
||
emitFromClientX(e.clientX);
|
||
},
|
||
[disabled, emitFromClientX],
|
||
);
|
||
|
||
const onKeyDown = useCallback(
|
||
(e) => {
|
||
if (disabled) return;
|
||
let delta = 0;
|
||
const page = hi - lo;
|
||
const big = Math.max(safeStep, page / 10);
|
||
switch (e.key) {
|
||
case "ArrowRight":
|
||
case "ArrowUp":
|
||
delta = safeStep;
|
||
break;
|
||
case "ArrowLeft":
|
||
case "ArrowDown":
|
||
delta = -safeStep;
|
||
break;
|
||
case "Home":
|
||
e.preventDefault();
|
||
onChange(lo);
|
||
return;
|
||
case "End":
|
||
e.preventDefault();
|
||
onChange(hi);
|
||
return;
|
||
case "PageUp":
|
||
delta = big;
|
||
break;
|
||
case "PageDown":
|
||
delta = -big;
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const next = clamp(clamped + delta, lo, hi);
|
||
if (safeStep > 0) {
|
||
const steps = Math.round((next - lo) / safeStep);
|
||
onChange(clamp(lo + steps * safeStep, lo, hi));
|
||
} else {
|
||
onChange(next);
|
||
}
|
||
},
|
||
[clamped, disabled, hi, lo, onChange, safeStep],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!focused) return;
|
||
const onDocPointerUp = () => {
|
||
draggingRef.current = false;
|
||
};
|
||
document.addEventListener("pointerup", onDocPointerUp);
|
||
document.addEventListener("pointercancel", onDocPointerUp);
|
||
return () => {
|
||
document.removeEventListener("pointerup", onDocPointerUp);
|
||
document.removeEventListener("pointercancel", onDocPointerUp);
|
||
};
|
||
}, [focused]);
|
||
|
||
return (
|
||
<div className="slider-wrap">
|
||
<div
|
||
ref={wrapRef}
|
||
className={cx("slider", disabled && "slider--disabled", className)}
|
||
>
|
||
<div className="slider__rail" aria-hidden />
|
||
<div
|
||
className="slider__track"
|
||
style={{ left: 0, width: `${percent}%` }}
|
||
aria-hidden
|
||
/>
|
||
<div
|
||
role="presentation"
|
||
className="slider__rail-hit"
|
||
onPointerDown={onRailPointerDown}
|
||
onPointerMove={onRailPointerMove}
|
||
onPointerUp={onRailPointerUp}
|
||
onPointerCancel={onRailPointerUp}
|
||
/>
|
||
<div
|
||
id={id}
|
||
className="slider__handle"
|
||
style={{ left: `${percent}%` }}
|
||
tabIndex={disabled ? -1 : 0}
|
||
role="slider"
|
||
aria-valuemin={lo}
|
||
aria-valuemax={hi}
|
||
aria-valuenow={clamped}
|
||
aria-disabled={disabled || undefined}
|
||
aria-label={ariaLabel}
|
||
aria-labelledby={ariaLabelledBy}
|
||
onPointerDown={onHandlePointerDown}
|
||
onKeyDown={onKeyDown}
|
||
onFocus={() => setFocused(true)}
|
||
onBlur={() => setFocused(false)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|