Audit fixes: a11y, gesture correctness, math edge cases
- Keyboard focus-visible ring on all controls (WCAG 2.4.7), suppressed for pointer grabs; focusRing prop to customize or disable - aria-orientation on track modes; data-dragging/-disabled/-readonly/ -focus-visible state attributes; disabled dims, readOnly drops the grab cursor - Escape cancels a drag and restores the pre-gesture value - Controlled mode no longer desyncs when the parent rejects onChange - Second concurrent pointer can no longer hijack a drag session; drags cancel when disabled/readOnly flips mid-gesture - Wheel: onChangeStart symmetry, settle flush on unsubscribe, dominant- axis direction (fixes macOS Shift+scroll reversal), ignored mid-drag - Shift+wheel and fine multipliers now work on stepped/values knobs - Fader/LEDFader track mapping inset-aligned with handle travel (no value jump when grabbing the cap); LEDFader zones sorted - roundTo no longer returns NaN for exponential-notation values; logTaper falls back to linear for invalid ranges; decimalsFromStep shaves float noise; findClosest([]) returns the input - Double-click reset brackets with onChangeStart/End - MetalKnob honors showValue/unit/format; VintageKnob omits them from its type; TickLabels extracted as a public primitive - SegmentDisplay: letters/hex charset, dash overflow instead of 888
This commit is contained in:
parent
60a02cb1db
commit
da352d3695
13 changed files with 378 additions and 82 deletions
|
|
@ -6,6 +6,11 @@ import { useKnob } from '../hooks/useKnob';
|
|||
export interface KnobProps extends KnobCoreProps {
|
||||
/** Square canvas size in px. Default: 80. */
|
||||
size?: number;
|
||||
/**
|
||||
* Color of the keyboard focus ring, or `false` to disable it (only do this
|
||||
* if you render your own focus indicator). Default: a soft blue.
|
||||
*/
|
||||
focusRing?: string | false;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
||||
|
|
@ -21,6 +26,7 @@ export interface KnobProps extends KnobCoreProps {
|
|||
*/
|
||||
export const Knob: React.FC<KnobProps> = ({
|
||||
size = 80,
|
||||
focusRing = 'rgba(130,180,255,0.65)',
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
|
|
@ -52,6 +58,10 @@ export const Knob: React.FC<KnobProps> = ({
|
|||
position: 'relative',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
...(knob.isFocusVisible && focusRing
|
||||
? { boxShadow: `0 0 0 2px ${focusRing}` }
|
||||
: undefined),
|
||||
...knob.bind.style,
|
||||
...style,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ describe('decimalsFromStep', () => {
|
|||
expect(decimalsFromStep(0)).toBe(3);
|
||||
expect(decimalsFromStep(NaN)).toBe(3);
|
||||
});
|
||||
it('shaves float noise from computed steps', () => {
|
||||
expect(decimalsFromStep(0.1 + 0.2)).toBe(1); // 0.30000000000000004 -> 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundTo', () => {
|
||||
|
|
@ -43,6 +46,16 @@ describe('roundTo', () => {
|
|||
expect(roundTo(123.456789, 4)).toBe(123.4568);
|
||||
expect(roundTo(5, 0)).toBe(5);
|
||||
});
|
||||
it('handles values that stringify exponentially', () => {
|
||||
expect(roundTo(1e-8, 10)).toBe(1e-8);
|
||||
expect(roundTo(2.5e-7, 7)).toBe(3e-7);
|
||||
expect(roundTo(1.5e21, 0)).toBe(1.5e21);
|
||||
expect(Number.isNaN(roundTo(1e-9, 3))).toBe(false);
|
||||
});
|
||||
it('passes non-finite values through', () => {
|
||||
expect(roundTo(NaN, 2)).toBeNaN();
|
||||
expect(roundTo(Infinity, 2)).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapToStep', () => {
|
||||
|
|
@ -61,6 +74,9 @@ describe('findClosest', () => {
|
|||
expect(findClosest([1, 100, 5, 50], 42)).toBe(50);
|
||||
expect(findClosest([0.25, 0.5, 1, 2, 4], 0.8)).toBe(1);
|
||||
});
|
||||
it('returns the input value for an empty list', () => {
|
||||
expect(findClosest([], 5)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tapers', () => {
|
||||
|
|
@ -74,6 +90,11 @@ describe('tapers', () => {
|
|||
roundTo(Math.log(100) / Math.log(1000), 4),
|
||||
);
|
||||
});
|
||||
it('log taper falls back to linear for invalid ranges instead of NaN', () => {
|
||||
expect(logTaper.fromNormalized(0.5, 0, 100)).toBe(50);
|
||||
expect(logTaper.toNormalized(0, -10, 10)).toBe(0.5);
|
||||
expect(Number.isNaN(logTaper.fromNormalized(0.5, -10, 10))).toBe(false);
|
||||
});
|
||||
it('pow taper round-trips', () => {
|
||||
const t = powTaper(2);
|
||||
const n = t.toNormalized(t.fromNormalized(0.3, 0, 10), 0, 10);
|
||||
|
|
|
|||
|
|
@ -8,17 +8,26 @@ export const clamp = (value: number, min: number, max: number): number =>
|
|||
*/
|
||||
export const decimalsFromStep = (step: number): number => {
|
||||
if (!Number.isFinite(step) || step <= 0) return 3;
|
||||
const s = step.toString();
|
||||
// Shave float noise (0.1 + 0.2 -> 0.30000000000000004 must read as 1 decimal).
|
||||
const s = roundTo(step, 12).toString();
|
||||
const exp = s.indexOf('e-');
|
||||
if (exp !== -1) return parseInt(s.slice(exp + 2), 10);
|
||||
if (exp !== -1) return Math.min(parseInt(s.slice(exp + 2), 10), 12);
|
||||
const dot = s.indexOf('.');
|
||||
return dot === -1 ? 0 : s.length - dot - 1;
|
||||
return dot === -1 ? 0 : Math.min(s.length - dot - 1, 12);
|
||||
};
|
||||
|
||||
/** Round to a fixed number of decimal places without float drift (0.1 + 0.2 style). */
|
||||
export const roundTo = (value: number, decimals: number): number => {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
const d = clamp(Math.trunc(decimals), 0, 15);
|
||||
return Number(`${Math.round(Number(`${value}e${d}`))}e-${d}`);
|
||||
const s = `${value}`;
|
||||
// The string-shift trick breaks on exponential notation (|v| >= 1e21 or
|
||||
// |v| < 1e-7 stringify as "1e-8" etc.) — fall back to plain scaling there.
|
||||
if (s.includes('e') || s.includes('E')) {
|
||||
const f = Math.pow(10, d);
|
||||
return Math.round(value * f) / f;
|
||||
}
|
||||
return Number(`${Math.round(Number(`${s}e${d}`))}e-${d}`);
|
||||
};
|
||||
|
||||
/** Snap `value` to the nearest multiple of `step`, anchored at `min`. */
|
||||
|
|
@ -27,8 +36,9 @@ export const snapToStep = (value: number, step: number, min: number): number =>
|
|||
return min + Math.round((value - min) / step) * step;
|
||||
};
|
||||
|
||||
/** Find the entry of `values` closest to `value`. */
|
||||
/** Find the entry of `values` closest to `value`. Returns `value` for an empty list. */
|
||||
export const findClosest = (values: readonly number[], value: number): number => {
|
||||
if (values.length === 0) return value;
|
||||
let best = values[0];
|
||||
let bestDelta = Infinity;
|
||||
for (const v of values) {
|
||||
|
|
@ -62,13 +72,21 @@ export const linearTaper: Taper = {
|
|||
/**
|
||||
* Logarithmic taper (equal ratios per travel). Requires min and max to be
|
||||
* non-zero and share a sign — ideal for frequency ranges like 20..20000 Hz.
|
||||
* Invalid ranges (zero or mixed-sign) fall back to linear instead of
|
||||
* emitting NaN.
|
||||
*/
|
||||
const logUsable = (min: number, max: number): boolean =>
|
||||
min !== 0 && max !== 0 && Math.sign(min) === Math.sign(max) && min !== max;
|
||||
|
||||
export const logTaper: Taper = {
|
||||
toNormalized: (value, min, max) => {
|
||||
if (min === max) return 0;
|
||||
if (!logUsable(min, max)) return linearTaper.toNormalized(value, min, max);
|
||||
return Math.log(value / min) / Math.log(max / min);
|
||||
},
|
||||
fromNormalized: (n, min, max) => min * Math.pow(max / min, n),
|
||||
fromNormalized: (n, min, max) => {
|
||||
if (!logUsable(min, max)) return linearTaper.fromNormalized(n, min, max);
|
||||
return min * Math.pow(max / min, n);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ export interface KnobCoreProps {
|
|||
interaction?: InteractionMode;
|
||||
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
|
||||
dragSensitivity?: number;
|
||||
/**
|
||||
* Dead margin (px) at each end of the element in `track-*` modes, so pointer
|
||||
* mapping lines up with a handle that can't travel the full rect (half the
|
||||
* handle length, typically). Default: 0.
|
||||
*/
|
||||
trackInset?: number;
|
||||
/** Multiplier applied while Shift is held during relative drags / wheel. Default: 0.1. */
|
||||
fineMultiplier?: number;
|
||||
/** Angle (deg clockwise from 12 o'clock) where travel begins. Default: 225. */
|
||||
|
|
|
|||
|
|
@ -22,9 +22,41 @@ const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
|
|||
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
|
||||
'9': ['a', 'b', 'c', 'd', 'f', 'g'],
|
||||
'-': ['g'],
|
||||
'_': ['d'],
|
||||
'=': ['g', 'd'],
|
||||
' ': [],
|
||||
// Letters, as far as seven segments allow — enough for hex, "OFF", "On",
|
||||
// "LP"/"HP", "Err", "dB" and friends.
|
||||
A: ['a', 'b', 'c', 'e', 'f', 'g'],
|
||||
b: ['c', 'd', 'e', 'f', 'g'],
|
||||
C: ['a', 'd', 'e', 'f'],
|
||||
c: ['d', 'e', 'g'],
|
||||
d: ['b', 'c', 'd', 'e', 'g'],
|
||||
E: ['a', 'd', 'e', 'f', 'g'],
|
||||
F: ['a', 'e', 'f', 'g'],
|
||||
G: ['a', 'c', 'd', 'e', 'f'],
|
||||
H: ['b', 'c', 'e', 'f', 'g'],
|
||||
h: ['c', 'e', 'f', 'g'],
|
||||
I: ['b', 'c'],
|
||||
J: ['b', 'c', 'd'],
|
||||
L: ['d', 'e', 'f'],
|
||||
n: ['c', 'e', 'g'],
|
||||
O: ['a', 'b', 'c', 'd', 'e', 'f'],
|
||||
o: ['c', 'd', 'e', 'g'],
|
||||
P: ['a', 'b', 'e', 'f', 'g'],
|
||||
q: ['a', 'b', 'c', 'f', 'g'],
|
||||
r: ['e', 'g'],
|
||||
S: ['a', 'c', 'd', 'f', 'g'],
|
||||
t: ['d', 'e', 'f', 'g'],
|
||||
U: ['b', 'c', 'd', 'e', 'f'],
|
||||
u: ['c', 'd', 'e'],
|
||||
y: ['b', 'c', 'd', 'f', 'g'],
|
||||
};
|
||||
|
||||
/** Resolve a character to its segments, trying the other case before giving up. */
|
||||
const segmentsFor = (ch: string): SegmentKey[] =>
|
||||
CHAR_SEGMENTS[ch] ?? CHAR_SEGMENTS[ch.toUpperCase()] ?? CHAR_SEGMENTS[ch.toLowerCase()] ?? [];
|
||||
|
||||
const H_HALF = 1.1; // half thickness
|
||||
const INSET = 0.45; // gap between adjacent segments
|
||||
|
||||
|
|
@ -78,7 +110,7 @@ export const renderSegmentText = (
|
|||
);
|
||||
continue;
|
||||
}
|
||||
const lit = new Set(CHAR_SEGMENTS[ch] ?? []);
|
||||
const lit = new Set(segmentsFor(ch));
|
||||
const digit: React.ReactNode[] = [];
|
||||
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
|
||||
const on = lit.has(seg);
|
||||
|
|
@ -117,6 +149,8 @@ export const formatForDisplay = (
|
|||
): string => {
|
||||
let text = value.toFixed(decimals);
|
||||
const cellCount = (s: string) => s.replace(/\./g, '').length;
|
||||
if (cellCount(text) > digits) text = ''.padStart(digits, '8'); // overflow
|
||||
// Doesn't fit: dashes, the hardware overflow convention. All-8s would read
|
||||
// as a legitimate value.
|
||||
if (cellCount(text) > digits) text = ''.padStart(digits, '-');
|
||||
return text.padStart(digits + (text.includes('.') ? 1 : 0), ' ');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
import type { KnobCoreProps, KnobState } from '../core/types';
|
||||
|
||||
export interface UseKnobResult extends KnobState {
|
||||
/** True when focus came from the keyboard — render a focus ring. */
|
||||
isFocusVisible: boolean;
|
||||
/** Ref for the interactive element. Required for wheel + rotary geometry. */
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
/** Spread onto the interactive element. */
|
||||
|
|
@ -24,17 +26,25 @@ export interface UseKnobResult extends KnobState {
|
|||
onPointerCancel: (e: React.PointerEvent) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onKeyUp: (e: React.KeyboardEvent) => void;
|
||||
onFocus: (e: React.FocusEvent) => void;
|
||||
onBlur: (e: React.FocusEvent) => void;
|
||||
onDoubleClick: (e: React.MouseEvent) => void;
|
||||
role: 'slider';
|
||||
tabIndex: number;
|
||||
'aria-valuemin': number;
|
||||
'aria-valuemax': number;
|
||||
'aria-valuenow': number;
|
||||
'aria-orientation'?: 'horizontal' | 'vertical';
|
||||
'aria-valuetext'?: string;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
'aria-disabled'?: boolean;
|
||||
'aria-readonly'?: boolean;
|
||||
/** Present while dragging — style with CSS `[data-dragging]`. */
|
||||
'data-dragging'?: string;
|
||||
'data-disabled'?: string;
|
||||
'data-readonly'?: string;
|
||||
'data-focus-visible'?: string;
|
||||
style: React.CSSProperties;
|
||||
};
|
||||
/** Imperatively set the value (snapped + clamped). */
|
||||
|
|
@ -50,18 +60,21 @@ interface DragSession {
|
|||
lastY: number;
|
||||
/** Continuous normalized position, kept un-snapped for smooth relative drags. */
|
||||
n: number;
|
||||
/** Value at gesture start, restored when Escape cancels the drag. */
|
||||
startValue: number;
|
||||
}
|
||||
|
||||
export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||
const {
|
||||
min = 0,
|
||||
max = 100,
|
||||
min: rawMin = 0,
|
||||
max: rawMax = 100,
|
||||
step = 0,
|
||||
values,
|
||||
steps,
|
||||
taper = linearTaper,
|
||||
interaction = 'rotary',
|
||||
dragSensitivity = 200,
|
||||
trackInset = 0,
|
||||
fineMultiplier = 0.1,
|
||||
angleOffset = 225,
|
||||
angleRange = 270,
|
||||
|
|
@ -74,6 +87,11 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
getAriaValueText,
|
||||
} = props;
|
||||
|
||||
// Normalize a reversed range rather than half-breaking (negative spans
|
||||
// invert keyboard directions and produce invalid ARIA min/max).
|
||||
const min = Math.min(rawMin, rawMax);
|
||||
const max = Math.max(rawMin, rawMax);
|
||||
|
||||
const decimals = props.decimals ?? (step > 0 ? decimalsFromStep(step) : 3);
|
||||
const doubleClickReset =
|
||||
props.doubleClickReset ?? props.defaultValue !== undefined;
|
||||
|
|
@ -82,7 +100,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
/** Clamp, snap and round a raw value into an emittable one. */
|
||||
const constrain = React.useCallback(
|
||||
(raw: number): number => {
|
||||
let v = clamp(raw, Math.min(min, max), Math.max(min, max));
|
||||
let v = clamp(raw, min, max);
|
||||
if (values && values.length > 0) {
|
||||
v = findClosest(values, v);
|
||||
} else if (steps && steps > 1) {
|
||||
|
|
@ -90,7 +108,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
const snapped = Math.round(n * (steps - 1)) / (steps - 1);
|
||||
v = taper.fromNormalized(snapped, min, max);
|
||||
} else if (step > 0) {
|
||||
v = clamp(snapToStep(v, step, min), Math.min(min, max), Math.max(min, max));
|
||||
v = clamp(snapToStep(v, step, min), min, max);
|
||||
}
|
||||
return roundTo(v, decimals);
|
||||
},
|
||||
|
|
@ -103,16 +121,28 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
);
|
||||
const value = constrain(isControlled ? (props.value as number) : internalValue);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [isFocusVisible, setIsFocusVisible] = React.useState(false);
|
||||
|
||||
const valueRef = React.useRef(value);
|
||||
valueRef.current = value;
|
||||
// Callbacks live in refs so changing an inline prop doesn't tear down the
|
||||
// wheel listener or invalidate the drag handlers mid-gesture.
|
||||
const onChangeEndRef = React.useRef(onChangeEnd);
|
||||
onChangeEndRef.current = onChangeEnd;
|
||||
const onChangeStartRef = React.useRef(onChangeStart);
|
||||
onChangeStartRef.current = onChangeStart;
|
||||
|
||||
const emit = React.useCallback(
|
||||
(raw: number) => {
|
||||
const next = constrain(raw);
|
||||
if (next === valueRef.current) return;
|
||||
valueRef.current = next;
|
||||
if (!isControlled) setInternalValue(next);
|
||||
// Only pre-commit in uncontrolled mode. In controlled mode the parent
|
||||
// owns the value — if it rejects/clamps the change, valueRef must keep
|
||||
// tracking the rendered value or nudges would advance a phantom state.
|
||||
if (!isControlled) {
|
||||
valueRef.current = next;
|
||||
setInternalValue(next);
|
||||
}
|
||||
onChange?.(next);
|
||||
},
|
||||
[constrain, isControlled, onChange],
|
||||
|
|
@ -140,11 +170,15 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
break;
|
||||
}
|
||||
case 'track-vertical': {
|
||||
setFromNormalized((s.rect.bottom - clientY) / s.rect.height);
|
||||
const travel = s.rect.height - trackInset * 2;
|
||||
if (travel <= 0) break;
|
||||
setFromNormalized((s.rect.bottom - trackInset - clientY) / travel);
|
||||
break;
|
||||
}
|
||||
case 'track-horizontal': {
|
||||
setFromNormalized((clientX - s.rect.left) / s.rect.width);
|
||||
const travel = s.rect.width - trackInset * 2;
|
||||
if (travel <= 0) break;
|
||||
setFromNormalized((clientX - s.rect.left - trackInset) / travel);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
|
@ -161,14 +195,18 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
s.lastX = clientX;
|
||||
s.lastY = clientY;
|
||||
},
|
||||
[interaction, angleOffset, angleRange, dragSensitivity, fineMultiplier, setFromNormalized],
|
||||
[interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromNormalized],
|
||||
);
|
||||
|
||||
const onPointerDown = React.useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!interactive || !ref.current) return;
|
||||
// A drag is single-pointer: ignore extra touches instead of letting a
|
||||
// second finger hijack the session.
|
||||
if (session.current) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
pointerFocus.current = true;
|
||||
ref.current.focus({ preventScroll: true });
|
||||
ref.current.setPointerCapture(e.pointerId);
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
|
|
@ -180,52 +218,67 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
lastX: e.clientX,
|
||||
lastY: e.clientY,
|
||||
n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1),
|
||||
startValue: valueRef.current,
|
||||
};
|
||||
setIsDragging(true);
|
||||
onChangeStart?.(valueRef.current);
|
||||
onChangeStartRef.current?.(valueRef.current);
|
||||
// Fader-style modes jump to the pressed position immediately.
|
||||
if (interaction === 'track-vertical' || interaction === 'track-horizontal') {
|
||||
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
||||
}
|
||||
},
|
||||
[interactive, interaction, taper, min, max, onChangeStart, applyPointer],
|
||||
[interactive, interaction, taper, min, max, applyPointer],
|
||||
);
|
||||
|
||||
const onPointerMove = React.useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!interactive) return;
|
||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
||||
},
|
||||
[applyPointer],
|
||||
[interactive, applyPointer],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
onChangeEnd?.(valueRef.current);
|
||||
},
|
||||
[onChangeEnd],
|
||||
);
|
||||
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
|
||||
// otherwise it would keep emitting while rendered as disabled.
|
||||
React.useEffect(() => {
|
||||
if (interactive || !session.current) return;
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
onChangeEndRef.current?.(valueRef.current);
|
||||
}, [interactive]);
|
||||
|
||||
const endDrag = React.useCallback((e: React.PointerEvent) => {
|
||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
onChangeEndRef.current?.(valueRef.current);
|
||||
}, []);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Wheel — needs a non-passive listener so preventDefault stops page scroll.
|
||||
// -------------------------------------------------------------------------
|
||||
const wheelSettle = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const wheelActive = React.useRef(false);
|
||||
const nudge = React.useCallback(
|
||||
(direction: number, multiplier = 1) => {
|
||||
const current = valueRef.current;
|
||||
// Discrete controls move whole detents: fine mode still moves one,
|
||||
// coarse mode jumps several.
|
||||
const detentCount = Math.max(1, Math.round(multiplier));
|
||||
if (values && values.length > 0) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const idx = sorted.indexOf(findClosest(sorted, current));
|
||||
emit(sorted[clamp(idx + direction, 0, sorted.length - 1)]);
|
||||
emit(sorted[clamp(idx + direction * detentCount, 0, sorted.length - 1)]);
|
||||
} else if (steps && steps > 1) {
|
||||
const n = taper.toNormalized(current, min, max);
|
||||
setFromNormalized(n + direction / (steps - 1));
|
||||
setFromNormalized(n + (direction * detentCount) / (steps - 1));
|
||||
} else if (step > 0) {
|
||||
// A fractional multiplier would snap straight back to the current
|
||||
// step — never emit less than one step.
|
||||
emit(current + direction * step * Math.max(1, multiplier));
|
||||
} else {
|
||||
const base = step > 0 ? step : (max - min) / 100;
|
||||
emit(current + direction * base * multiplier);
|
||||
emit(current + direction * ((max - min) / 100) * multiplier);
|
||||
}
|
||||
},
|
||||
[values, steps, step, min, max, taper, emit, setFromNormalized],
|
||||
|
|
@ -238,17 +291,37 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
if (!el || !enableWheel || !interactive) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const direction = e.deltaY < 0 || e.deltaX > 0 ? 1 : -1;
|
||||
// A wheel tick mid-drag would be undone by the next pointermove
|
||||
// (the drag session tracks its own position) — ignore it.
|
||||
if (session.current) return;
|
||||
if (!wheelActive.current) {
|
||||
wheelActive.current = true;
|
||||
onChangeStartRef.current?.(valueRef.current);
|
||||
}
|
||||
// Dominant axis, scroll-up / scroll-right increases. Using deltaY with
|
||||
// deltaX only as fallback keeps macOS Shift+scroll (which remaps Y to X)
|
||||
// from reversing direction.
|
||||
const d = e.deltaY !== 0 ? -e.deltaY : -e.deltaX;
|
||||
const direction = d > 0 ? 1 : -1;
|
||||
nudgeRef.current(direction, e.shiftKey ? fineMultiplier : 1);
|
||||
clearTimeout(wheelSettle.current);
|
||||
wheelSettle.current = setTimeout(() => onChangeEnd?.(valueRef.current), 250);
|
||||
wheelSettle.current = setTimeout(() => {
|
||||
wheelActive.current = false;
|
||||
onChangeEndRef.current?.(valueRef.current);
|
||||
}, 250);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
clearTimeout(wheelSettle.current);
|
||||
// Flush a pending settle so re-subscription/unmount can't eat the
|
||||
// gesture's onChangeEnd.
|
||||
if (wheelActive.current) {
|
||||
wheelActive.current = false;
|
||||
onChangeEndRef.current?.(valueRef.current);
|
||||
}
|
||||
};
|
||||
}, [enableWheel, interactive, fineMultiplier, onChangeEnd]);
|
||||
}, [enableWheel, interactive, fineMultiplier]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keyboard
|
||||
|
|
@ -257,6 +330,17 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
const onKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!interactive) return;
|
||||
// Escape cancels an in-flight drag, restoring the pre-gesture value.
|
||||
if (e.key === 'Escape') {
|
||||
const s = session.current;
|
||||
if (!s) return;
|
||||
e.preventDefault();
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
emit(s.startValue);
|
||||
onChangeEndRef.current?.(s.startValue);
|
||||
return;
|
||||
}
|
||||
const span = max - min;
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
|
|
@ -294,15 +378,34 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
const onKeyUp = React.useCallback(() => {
|
||||
if (keyAdjusted.current) {
|
||||
keyAdjusted.current = false;
|
||||
onChangeEnd?.(valueRef.current);
|
||||
onChangeEndRef.current?.(valueRef.current);
|
||||
}
|
||||
}, [onChangeEnd]);
|
||||
}, []);
|
||||
|
||||
const pointerFocus = React.useRef(false);
|
||||
const onFocus = React.useCallback(() => {
|
||||
// Only show the ring for keyboard focus. Browsers treat our programmatic
|
||||
// .focus() during pointerdown as :focus-visible, so track it ourselves.
|
||||
setIsFocusVisible(!pointerFocus.current);
|
||||
pointerFocus.current = false;
|
||||
}, []);
|
||||
|
||||
const onBlur = React.useCallback(() => {
|
||||
setIsFocusVisible(false);
|
||||
// Flush a pending gesture end if the user tabs away while holding a key.
|
||||
if (keyAdjusted.current) {
|
||||
keyAdjusted.current = false;
|
||||
onChangeEndRef.current?.(valueRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDoubleClick = React.useCallback(() => {
|
||||
if (!interactive || !doubleClickReset || props.defaultValue === undefined) return;
|
||||
emit(props.defaultValue);
|
||||
onChangeEnd?.(valueRef.current);
|
||||
}, [interactive, doubleClickReset, props.defaultValue, emit, onChangeEnd]);
|
||||
const target = constrain(props.defaultValue);
|
||||
onChangeStartRef.current?.(valueRef.current);
|
||||
emit(target);
|
||||
onChangeEndRef.current?.(target);
|
||||
}, [interactive, doubleClickReset, props.defaultValue, emit, constrain]);
|
||||
|
||||
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
||||
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
||||
|
|
@ -312,6 +415,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
normalized,
|
||||
angle,
|
||||
isDragging,
|
||||
isFocusVisible,
|
||||
min,
|
||||
max,
|
||||
decimals,
|
||||
|
|
@ -327,22 +431,41 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
onPointerCancel: endDrag,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onDoubleClick,
|
||||
role: 'slider',
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
'aria-valuemin': min,
|
||||
'aria-valuemax': max,
|
||||
'aria-valuenow': value,
|
||||
'aria-orientation':
|
||||
interaction === 'track-vertical'
|
||||
? ('vertical' as const)
|
||||
: interaction === 'track-horizontal'
|
||||
? ('horizontal' as const)
|
||||
: undefined,
|
||||
'aria-valuetext': getAriaValueText ? getAriaValueText(value) : undefined,
|
||||
'aria-label': props['aria-label'],
|
||||
'aria-labelledby': props['aria-labelledby'],
|
||||
'aria-disabled': disabled || undefined,
|
||||
'aria-readonly': readOnly || undefined,
|
||||
'data-dragging': isDragging ? '' : undefined,
|
||||
'data-disabled': disabled ? '' : undefined,
|
||||
'data-readonly': readOnly ? '' : undefined,
|
||||
'data-focus-visible': isFocusVisible ? '' : undefined,
|
||||
style: {
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'grab',
|
||||
cursor: disabled
|
||||
? 'not-allowed'
|
||||
: readOnly
|
||||
? 'default'
|
||||
: isDragging
|
||||
? 'grabbing'
|
||||
: 'grab',
|
||||
opacity: disabled ? 0.45 : undefined,
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export {
|
|||
export { Arc, type ArcProps } from './primitives/Arc';
|
||||
export { Pointer, type PointerProps } from './primitives/Pointer';
|
||||
export { Ticks, type TicksProps } from './primitives/Ticks';
|
||||
export { TickLabels, type TickLabelsProps } from './primitives/TickLabels';
|
||||
export { Face, type FaceProps } from './primitives/Face';
|
||||
export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from './primitives/Text';
|
||||
|
||||
|
|
|
|||
53
packages/dreamknob/src/primitives/TickLabels.tsx
Normal file
53
packages/dreamknob/src/primitives/TickLabels.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { polarToCartesian } from '../core/math';
|
||||
|
||||
export interface TickLabelsProps {
|
||||
/** Labels distributed evenly across the travel, e.g. ['0','','5','','10']. */
|
||||
labels: readonly string[];
|
||||
/** Radius of the label ring. Defaults to just inside the canvas edge. */
|
||||
radius?: number;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontFamily?: string;
|
||||
/** Extra props per label, by index. */
|
||||
getLabelProps?: (index: number) => React.SVGProps<SVGTextElement> | undefined;
|
||||
}
|
||||
|
||||
/** Printed scale labels around the knob's travel (0…10 amp-style scales). */
|
||||
export const TickLabels: React.FC<TickLabelsProps> = ({
|
||||
labels,
|
||||
radius,
|
||||
fontSize,
|
||||
color = 'currentColor',
|
||||
fontFamily,
|
||||
getLabelProps,
|
||||
}) => {
|
||||
const { size, center: c, angleOffset, angleRange } = useKnobContext();
|
||||
const r = radius ?? size / 2 - size * 0.02;
|
||||
return (
|
||||
<g>
|
||||
{labels.map((text, i) => {
|
||||
if (!text) return null;
|
||||
const t = labels.length === 1 ? 0 : i / (labels.length - 1);
|
||||
const p = polarToCartesian(c, c, r, angleOffset + t * angleRange);
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={fontSize ?? size * 0.085}
|
||||
fontFamily={fontFamily}
|
||||
fill={color}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
{...getLabelProps?.(i)}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,6 +5,8 @@ import { MONO_FONT, UI_FONT } from './shared';
|
|||
|
||||
export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
/** Keyboard focus ring color, or false to disable. */
|
||||
focusRing?: string | false;
|
||||
/** Travel length in px. Default: 160. */
|
||||
length?: number;
|
||||
/** Cross-axis size in px. Default: 44. */
|
||||
|
|
@ -28,6 +30,7 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
|||
/** Studio channel fader: absolute-position linear control with a cap handle. */
|
||||
export const Fader: React.FC<FaderProps> = ({
|
||||
orientation = 'vertical',
|
||||
focusRing = 'rgba(130,180,255,0.65)',
|
||||
length = 160,
|
||||
breadth = 44,
|
||||
color = '#4cc2ff',
|
||||
|
|
@ -46,13 +49,16 @@ export const Fader: React.FC<FaderProps> = ({
|
|||
...core
|
||||
}) => {
|
||||
const vertical = orientation === 'vertical';
|
||||
const capMain = 20; // handle size along the travel axis
|
||||
const knob = useKnob({
|
||||
...core,
|
||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||
// Align pointer mapping with the handle's travel so grabbing the cap
|
||||
// doesn't jump the value.
|
||||
trackInset: capMain / 2,
|
||||
});
|
||||
const id = React.useId();
|
||||
|
||||
const capMain = 20; // handle size along the travel axis
|
||||
const capCross = breadth * 0.62;
|
||||
const trackW = Math.max(4, breadth * 0.12);
|
||||
const w = vertical ? breadth : length;
|
||||
|
|
@ -118,7 +124,19 @@ export const Fader: React.FC<FaderProps> = ({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div {...knob.bind} style={{ ...knob.bind.style, width: w, height: h, display: 'inline-flex' }}>
|
||||
<div
|
||||
{...knob.bind}
|
||||
style={{
|
||||
...knob.bind.style,
|
||||
width: w,
|
||||
height: h,
|
||||
display: 'inline-flex',
|
||||
borderRadius: 8,
|
||||
...(knob.isFocusVisible && focusRing
|
||||
? { boxShadow: `0 0 0 2px ${focusRing}` }
|
||||
: undefined),
|
||||
}}
|
||||
>
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2={vertical ? '0' : '1'} y2={vertical ? '1' : '0'}>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
|||
glow?: boolean;
|
||||
/** Well/panel color behind the LEDs. */
|
||||
faceColor?: string;
|
||||
/** Keyboard focus ring color, or false to disable. */
|
||||
focusRing?: string | false;
|
||||
label?: string;
|
||||
/** Seven-segment readout above the bar. Default: true. */
|
||||
showValue?: boolean;
|
||||
|
|
@ -53,6 +55,7 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
|||
offOpacity = 0.13,
|
||||
glow = true,
|
||||
faceColor = '#0b0d0e',
|
||||
focusRing = 'rgba(130,180,255,0.65)',
|
||||
label,
|
||||
showValue = true,
|
||||
digits = 4,
|
||||
|
|
@ -63,17 +66,21 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
|||
...core
|
||||
}) => {
|
||||
const vertical = orientation === 'vertical';
|
||||
const pad = 5;
|
||||
const knob = useKnob({
|
||||
...core,
|
||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||
trackInset: pad,
|
||||
});
|
||||
const id = React.useId();
|
||||
const glowId = `${id}-glow`;
|
||||
|
||||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
const pad = 5;
|
||||
const zoneList: readonly LEDFaderZone[] = zones ?? [{ upTo: 1, color }];
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
||||
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
||||
[zones, color],
|
||||
);
|
||||
const zoneFor = (t: number): string =>
|
||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||
|
||||
|
|
@ -140,7 +147,19 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<div {...knob.bind} style={{ ...knob.bind.style, width: w, height: h, display: 'inline-flex' }}>
|
||||
<div
|
||||
{...knob.bind}
|
||||
style={{
|
||||
...knob.bind.style,
|
||||
width: w,
|
||||
height: h,
|
||||
display: 'inline-flex',
|
||||
borderRadius: 6,
|
||||
...(knob.isFocusVisible && focusRing
|
||||
? { boxShadow: `0 0 0 2px ${focusRing}` }
|
||||
: undefined),
|
||||
}}
|
||||
>
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
|
|
|
|||
|
|
@ -3,17 +3,19 @@ import { Knob } from '../components/Knob';
|
|||
import { Arc } from '../primitives/Arc';
|
||||
import { Pointer } from '../primitives/Pointer';
|
||||
import { Ticks } from '../primitives/Ticks';
|
||||
import { KnobLabel } from '../primitives/Text';
|
||||
import { UI_FONT, type SkinProps } from './shared';
|
||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface MetalKnobProps extends SkinProps {
|
||||
/** Accent color for the active ticks / value arc. */
|
||||
color?: string;
|
||||
/** Overall metal tone. 'silver' | 'dark' or any base hex for the cap. */
|
||||
/** Overall metal tone. */
|
||||
tone?: 'silver' | 'dark';
|
||||
tickColor?: string;
|
||||
labelColor?: string;
|
||||
indicatorColor?: string;
|
||||
/** Readout color when `showValue` is set. Defaults to a contrast pick for the tone. */
|
||||
textColor?: string;
|
||||
showArc?: boolean;
|
||||
tickCount?: number;
|
||||
}
|
||||
|
|
@ -26,9 +28,13 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
|||
tickColor = 'rgba(255,255,255,0.18)',
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
indicatorColor,
|
||||
textColor,
|
||||
showArc = true,
|
||||
tickCount = 21,
|
||||
label,
|
||||
showValue = false,
|
||||
unit,
|
||||
format,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
|
|
@ -112,6 +118,16 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
|||
<circle cx={c} cy={c} r={faceR * 0.5} fill="none" stroke="rgba(0,0,0,0.12)" strokeWidth="0.6" />
|
||||
|
||||
<Pointer radius={faceR - size * 0.02} length={faceR * 0.55} width={Math.max(2.5, size * 0.038)} color={indicator} />
|
||||
{showValue && (
|
||||
<KnobValue
|
||||
color={textColor ?? (silver ? '#26272c' : 'rgba(255,255,255,0.88)')}
|
||||
unit={unit}
|
||||
format={format}
|
||||
fontSize={size * 0.13}
|
||||
fontFamily={MONO_FONT}
|
||||
dy={size * 0.09}
|
||||
/>
|
||||
)}
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { Ticks } from '../primitives/Ticks';
|
||||
import { TickLabels } from '../primitives/TickLabels';
|
||||
import { KnobLabel } from '../primitives/Text';
|
||||
import { polarToCartesian } from '../core/math';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface VintageKnobProps extends SkinProps {
|
||||
/** VintageKnob has no numeric readout, so it doesn't accept readout props. */
|
||||
export interface VintageKnobProps extends Omit<SkinProps, 'showValue' | 'unit' | 'format'> {
|
||||
/** Knob body color. Classic options: cream '#efe6d0' or bakelite '#26221f'. */
|
||||
bodyColor?: string;
|
||||
/** Tick/scale color. */
|
||||
|
|
@ -90,33 +92,6 @@ const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const ScaleLabels: React.FC<{ labels: readonly string[]; color: string }> = ({ labels, color }) => {
|
||||
const { size, center: c, angleOffset, angleRange } = useKnobContext();
|
||||
return (
|
||||
<g>
|
||||
{labels.map((text, i) => {
|
||||
const t = labels.length === 1 ? 0 : i / (labels.length - 1);
|
||||
const p = polarToCartesian(c, c, size / 2 - size * 0.02, angleOffset + t * angleRange);
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={size * 0.085}
|
||||
fontFamily={UI_FONT}
|
||||
fill={color}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */
|
||||
export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||
size = 96,
|
||||
|
|
@ -140,7 +115,7 @@ export const VintageKnob: React.FC<VintageKnobProps> = ({
|
|||
color={scaleColor}
|
||||
activeColor={color ?? scaleColor}
|
||||
/>
|
||||
{scaleLabels && <ScaleLabels labels={scaleLabels} color={scaleColor} />}
|
||||
{scaleLabels && <TickLabels labels={scaleLabels} color={scaleColor} fontFamily={UI_FONT} />}
|
||||
<ChickenHead
|
||||
bodyColor={bodyColor}
|
||||
indicatorColor={
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue