Files
modern-sk/src/components/knob/index.tsx
T
2026-06-05 15:11:02 +03:00

262 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
ref={elRef}
id={knobId}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={valueNow}
aria-disabled={disabled || undefined}
className={cx(
'modern-sk-knob',
`modern-sk-knob--${accent}`,
isAnimated && 'modern-sk-knob--animated',
dragging && 'is-dragging',
disabled && 'modern-sk-knob--disabled',
className,
)}
style={size != null ? ({ '--knob-size': `${size}px` } as CSSProperties) : undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
>
<svg className="modern-sk-knob__gauge" viewBox="0 0 100 100" aria-hidden>
<path className="modern-sk-knob__track" d={TRACK_PATH} />
<path
className="modern-sk-knob__fill"
d={TRACK_PATH}
strokeDasharray={ARC_LEN}
strokeDashoffset={fillOffset}
/>
<g className="modern-sk-knob__ticks">
{ticks.map((t) => (
<line
key={t.key}
className={cx('modern-sk-knob__tick', t.on && 'is-on')}
x1={t.x1.toFixed(2)}
y1={t.y1.toFixed(2)}
x2={t.x2.toFixed(2)}
y2={t.y2.toFixed(2)}
/>
))}
</g>
</svg>
<div className="modern-sk-knob__cap">
<div className="modern-sk-knob__dial" style={{ transform: `rotate(${dialAngle}deg)` }}>
<span className="modern-sk-knob__pointer" />
</div>
<div className="modern-sk-knob__hub" />
</div>
</div>
);
};