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:
Dreamodus 2026-07-12 14:38:50 -07:00
parent 60a02cb1db
commit da352d3695
13 changed files with 378 additions and 82 deletions

View file

@ -135,8 +135,10 @@ export const Playground: React.FC = () => {
return <MetalKnob size={cfg.size} {...common} />;
case 'rubber':
return <RubberKnob size={cfg.size} {...common} />;
case 'vintage':
return <VintageKnob size={cfg.size} {...common} />;
case 'vintage': {
const { showValue: _sv, unit: _u, ...vintageProps } = common;
return <VintageKnob size={cfg.size} {...vintageProps} />;
}
case 'led':
return <LEDKnob size={cfg.size} {...common} />;
case 'neon':

View file

@ -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,
}}

View file

@ -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);

View file

@ -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);
},
};
/**

View file

@ -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. */

View file

@ -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), ' ');
};

View file

@ -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;
// 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;
if (!isControlled) setInternalValue(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 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);
onChangeEnd?.(valueRef.current);
},
[onChangeEnd],
);
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',
},
},

View file

@ -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';

View 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>
);
};

View file

@ -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'}>

View file

@ -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

View file

@ -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}

View file

@ -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={