import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, } from 'react'; import { cx } from '../utils'; type KnobAccent = 'lime' | 'ember'; type KnobProps = { /** Controlled value. */ value?: number; /** Uncontrolled starting value. Defaults to `min`. */ defaultValue?: number; /** Fires with the snapped value on every change. */ onValueChange?: (value: number) => void; min?: number; max?: number; /** Snap increment. `0`/omitted = continuous (no detents, no ticks). */ step?: number; /** Accent colour for the gauge fill + pointer. `'lime'` (default) or `'ember'`. */ accent?: KnobAccent; /** Diameter in px. Drives every internal measurement via `--knob-size`. */ size?: number; /** * Glide the gauge fill + pointer between detents. The dial always tracks the * pointer 1:1 while dragging; this only eases the *value* settle (keyboard, * wheel, drag-release) — like the stepped Slider. Defaults to `true` when * `step > 0`, `false` otherwise. */ animated?: boolean; disabled?: boolean; className?: string; id?: string; 'aria-label'?: string; 'aria-labelledby'?: string; }; // Gauge geometry: 270° sweep starting at the 7-o'clock position. R is the arc // radius in the 100×100 SVG viewBox. const START = -135; const SWEEP = 270; const R = 43; const polar = (cx: number, cy: number, r: number, aDeg: number): [number, number] => { const t = (aDeg * Math.PI) / 180; return [cx + r * Math.sin(t), cy - r * Math.cos(t)]; }; const arcPath = (a0: number, a1: number) => { if (a1 - a0 < 0.01) a1 = a0 + 0.01; const [x0, y0] = polar(50, 50, R, a0); const [x1, y1] = polar(50, 50, R, a1); return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; }; const TRACK_PATH = arcPath(START, START + SWEEP); // Arc length of the gauge. The fill is the full arc revealed via dashoffset, so // the glide eases ALONG the circle (transitioning `d` interpolates endpoints in // a straight chord and distorts the arc mid-animation). const ARC_LEN = (R * SWEEP * Math.PI) / 180; export const Knob = ({ value: valueProp, defaultValue, onValueChange, min = 0, max = 100, step = 0, accent = 'lime', size, animated, disabled = false, className, id, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, }: KnobProps) => { const span = max - min || 1; const stepped = step > 0; const isAnimated = animated !== undefined ? animated : stepped; const clamp = (v: number) => Math.min(max, Math.max(min, v)); const snap = (v: number) => stepped ? clamp(Math.round((v - min) / step) * step + min) : clamp(v); const isControlled = valueProp !== undefined; const [internal, setInternal] = useState(() => snap(defaultValue ?? min)); const committed = isControlled ? snap(valueProp) : internal; // Continuous, pointer-bound position for the dial; only meaningful mid-drag. const [visual, setVisual] = useState(committed); const [dragging, setDragging] = useState(false); const elRef = useRef(null); // Drag tracking: previous pointer angle + a continuous value accumulator. // Accumulating per-move deltas (each < 180°) instead of measuring from the // grab angle lets the dial wrap past ±180° without the value snapping. const lastAngle = useRef(0); const acc = useRef(0); const reactId = useId(); const knobId = id ?? reactId; // `continuous` keeps the dial on the raw pointer angle; otherwise the dial // settles (and glides) to the snapped detent. const commit = (raw: number, continuous: boolean) => { const c = clamp(raw); const snapped = snap(c); setVisual(continuous ? c : snapped); if (!isControlled) setInternal(snapped); if (snapped !== committed) onValueChange?.(snapped); }; const angleAt = (clientX: number, clientY: number) => { const el = elRef.current; if (!el) return 0; const r = el.getBoundingClientRect(); return ( (Math.atan2(clientX - (r.left + r.width / 2), -(clientY - (r.top + r.height / 2))) * 180) / Math.PI ); }; const onPointerDown = (e: PointerEvent) => { if (disabled) return; e.preventDefault(); elRef.current?.focus(); elRef.current?.setPointerCapture(e.pointerId); setDragging(true); lastAngle.current = angleAt(e.clientX, e.clientY); acc.current = clamp(committed); }; const onPointerMove = (e: PointerEvent) => { if (!dragging) return; const a = angleAt(e.clientX, e.clientY); let d = a - lastAngle.current; if (d > 180) d -= 360; if (d < -180) d += 360; lastAngle.current = a; // Clamp the accumulator (not just the committed value) so overshoot past // an end doesn't build a dead zone you must unwind before reversing. acc.current = clamp(acc.current + (d / SWEEP) * span); commit(acc.current, true); }; const endDrag = (e: PointerEvent) => { if (!dragging) return; elRef.current?.releasePointerCapture(e.pointerId); setDragging(false); }; // React's synthetic onWheel is passive, so preventDefault can't block page // scroll — attach a native non-passive listener. A ref keeps it current // without re-binding every render. const wheelRef = useRef<(e: WheelEvent) => void>(() => {}); wheelRef.current = (e) => { if (disabled) return; e.preventDefault(); commit(committed - (step || span / 100) * Math.sign(e.deltaY), false); }; useEffect(() => { const el = elRef.current; if (!el) return; const handler = (e: WheelEvent) => wheelRef.current(e); el.addEventListener('wheel', handler, { passive: false }); return () => el.removeEventListener('wheel', handler); }, []); const onKeyDown = (e: KeyboardEvent) => { if (disabled) return; const big = step || span / 10; const sm = step || span / 100; const k = e.key; if (k === 'ArrowUp' || k === 'ArrowRight') commit(committed + sm, false); else if (k === 'ArrowDown' || k === 'ArrowLeft') commit(committed - sm, false); else if (k === 'PageUp') commit(committed + big, false); else if (k === 'PageDown') commit(committed - big, false); else if (k === 'Home') commit(min, false); else if (k === 'End') commit(max, false); else return; e.preventDefault(); }; const dialValue = dragging ? visual : committed; const dialAngle = START + ((clamp(dialValue) - min) / span) * SWEEP; const fillOffset = ARC_LEN * (1 - (committed - min) / span); const valueNow = stepped ? committed : Math.round(committed); const ticks = stepped ? Array.from({ length: Math.round(span / step) + 1 }, (_, i) => { const a = START + (i / Math.round(span / step)) * SWEEP; const [x1, y1] = polar(50, 50, R + 6, a); const [x2, y2] = polar(50, 50, R + 10.5, a); const val = min + (i / Math.round(span / step)) * span; return { x1, y1, x2, y2, on: val <= committed + 1e-6, key: i }; }) : []; return (
{ticks.map((t) => ( ))}
); };