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 (