chore: knob upd & memories
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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);
|
||||
const a0 = useRef(0);
|
||||
const v0 = 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);
|
||||
a0.current = angleAt(e.clientX, e.clientY);
|
||||
v0.current = committed;
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
let d = angleAt(e.clientX, e.clientY) - a0.current;
|
||||
if (d > 180) d -= 360;
|
||||
if (d < -180) d += 360;
|
||||
commit(v0.current + (d / SWEEP) * span, 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>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export * from './select';
|
||||
export * from './selection';
|
||||
export * from './segmented-control';
|
||||
export * from './slider';
|
||||
export * from './knob';
|
||||
export * from './tabs';
|
||||
export * from './progress';
|
||||
export * from './badge';
|
||||
|
||||
Reference in New Issue
Block a user