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
|
|
@ -135,8 +135,10 @@ export const Playground: React.FC = () => {
|
||||||
return <MetalKnob size={cfg.size} {...common} />;
|
return <MetalKnob size={cfg.size} {...common} />;
|
||||||
case 'rubber':
|
case 'rubber':
|
||||||
return <RubberKnob size={cfg.size} {...common} />;
|
return <RubberKnob size={cfg.size} {...common} />;
|
||||||
case 'vintage':
|
case 'vintage': {
|
||||||
return <VintageKnob size={cfg.size} {...common} />;
|
const { showValue: _sv, unit: _u, ...vintageProps } = common;
|
||||||
|
return <VintageKnob size={cfg.size} {...vintageProps} />;
|
||||||
|
}
|
||||||
case 'led':
|
case 'led':
|
||||||
return <LEDKnob size={cfg.size} {...common} />;
|
return <LEDKnob size={cfg.size} {...common} />;
|
||||||
case 'neon':
|
case 'neon':
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import { useKnob } from '../hooks/useKnob';
|
||||||
export interface KnobProps extends KnobCoreProps {
|
export interface KnobProps extends KnobCoreProps {
|
||||||
/** Square canvas size in px. Default: 80. */
|
/** Square canvas size in px. Default: 80. */
|
||||||
size?: number;
|
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;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
/** 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> = ({
|
export const Knob: React.FC<KnobProps> = ({
|
||||||
size = 80,
|
size = 80,
|
||||||
|
focusRing = 'rgba(130,180,255,0.65)',
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
|
|
@ -52,6 +58,10 @@ export const Knob: React.FC<KnobProps> = ({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
...(knob.isFocusVisible && focusRing
|
||||||
|
? { boxShadow: `0 0 0 2px ${focusRing}` }
|
||||||
|
: undefined),
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ describe('decimalsFromStep', () => {
|
||||||
expect(decimalsFromStep(0)).toBe(3);
|
expect(decimalsFromStep(0)).toBe(3);
|
||||||
expect(decimalsFromStep(NaN)).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', () => {
|
describe('roundTo', () => {
|
||||||
|
|
@ -43,6 +46,16 @@ describe('roundTo', () => {
|
||||||
expect(roundTo(123.456789, 4)).toBe(123.4568);
|
expect(roundTo(123.456789, 4)).toBe(123.4568);
|
||||||
expect(roundTo(5, 0)).toBe(5);
|
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', () => {
|
describe('snapToStep', () => {
|
||||||
|
|
@ -61,6 +74,9 @@ describe('findClosest', () => {
|
||||||
expect(findClosest([1, 100, 5, 50], 42)).toBe(50);
|
expect(findClosest([1, 100, 5, 50], 42)).toBe(50);
|
||||||
expect(findClosest([0.25, 0.5, 1, 2, 4], 0.8)).toBe(1);
|
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', () => {
|
describe('tapers', () => {
|
||||||
|
|
@ -74,6 +90,11 @@ describe('tapers', () => {
|
||||||
roundTo(Math.log(100) / Math.log(1000), 4),
|
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', () => {
|
it('pow taper round-trips', () => {
|
||||||
const t = powTaper(2);
|
const t = powTaper(2);
|
||||||
const n = t.toNormalized(t.fromNormalized(0.3, 0, 10), 0, 10);
|
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 => {
|
export const decimalsFromStep = (step: number): number => {
|
||||||
if (!Number.isFinite(step) || step <= 0) return 3;
|
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-');
|
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('.');
|
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). */
|
/** Round to a fixed number of decimal places without float drift (0.1 + 0.2 style). */
|
||||||
export const roundTo = (value: number, decimals: number): number => {
|
export const roundTo = (value: number, decimals: number): number => {
|
||||||
|
if (!Number.isFinite(value)) return value;
|
||||||
const d = clamp(Math.trunc(decimals), 0, 15);
|
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`. */
|
/** 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;
|
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 => {
|
export const findClosest = (values: readonly number[], value: number): number => {
|
||||||
|
if (values.length === 0) return value;
|
||||||
let best = values[0];
|
let best = values[0];
|
||||||
let bestDelta = Infinity;
|
let bestDelta = Infinity;
|
||||||
for (const v of values) {
|
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
|
* 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.
|
* 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 = {
|
export const logTaper: Taper = {
|
||||||
toNormalized: (value, min, max) => {
|
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);
|
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;
|
interaction?: InteractionMode;
|
||||||
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
|
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
|
||||||
dragSensitivity?: number;
|
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. */
|
/** Multiplier applied while Shift is held during relative drags / wheel. Default: 0.1. */
|
||||||
fineMultiplier?: number;
|
fineMultiplier?: number;
|
||||||
/** Angle (deg clockwise from 12 o'clock) where travel begins. Default: 225. */
|
/** 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'],
|
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
|
||||||
'9': ['a', 'b', 'c', 'd', 'f', 'g'],
|
'9': ['a', 'b', 'c', 'd', 'f', 'g'],
|
||||||
'-': ['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 H_HALF = 1.1; // half thickness
|
||||||
const INSET = 0.45; // gap between adjacent segments
|
const INSET = 0.45; // gap between adjacent segments
|
||||||
|
|
||||||
|
|
@ -78,7 +110,7 @@ export const renderSegmentText = (
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lit = new Set(CHAR_SEGMENTS[ch] ?? []);
|
const lit = new Set(segmentsFor(ch));
|
||||||
const digit: React.ReactNode[] = [];
|
const digit: React.ReactNode[] = [];
|
||||||
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
|
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
|
||||||
const on = lit.has(seg);
|
const on = lit.has(seg);
|
||||||
|
|
@ -117,6 +149,8 @@ export const formatForDisplay = (
|
||||||
): string => {
|
): string => {
|
||||||
let text = value.toFixed(decimals);
|
let text = value.toFixed(decimals);
|
||||||
const cellCount = (s: string) => s.replace(/\./g, '').length;
|
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), ' ');
|
return text.padStart(digits + (text.includes('.') ? 1 : 0), ' ');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
import type { KnobCoreProps, KnobState } from '../core/types';
|
import type { KnobCoreProps, KnobState } from '../core/types';
|
||||||
|
|
||||||
export interface UseKnobResult extends KnobState {
|
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 for the interactive element. Required for wheel + rotary geometry. */
|
||||||
ref: React.RefObject<HTMLDivElement>;
|
ref: React.RefObject<HTMLDivElement>;
|
||||||
/** Spread onto the interactive element. */
|
/** Spread onto the interactive element. */
|
||||||
|
|
@ -24,17 +26,25 @@ export interface UseKnobResult extends KnobState {
|
||||||
onPointerCancel: (e: React.PointerEvent) => void;
|
onPointerCancel: (e: React.PointerEvent) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
onKeyUp: (e: React.KeyboardEvent) => void;
|
onKeyUp: (e: React.KeyboardEvent) => void;
|
||||||
|
onFocus: (e: React.FocusEvent) => void;
|
||||||
|
onBlur: (e: React.FocusEvent) => void;
|
||||||
onDoubleClick: (e: React.MouseEvent) => void;
|
onDoubleClick: (e: React.MouseEvent) => void;
|
||||||
role: 'slider';
|
role: 'slider';
|
||||||
tabIndex: number;
|
tabIndex: number;
|
||||||
'aria-valuemin': number;
|
'aria-valuemin': number;
|
||||||
'aria-valuemax': number;
|
'aria-valuemax': number;
|
||||||
'aria-valuenow': number;
|
'aria-valuenow': number;
|
||||||
|
'aria-orientation'?: 'horizontal' | 'vertical';
|
||||||
'aria-valuetext'?: string;
|
'aria-valuetext'?: string;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
'aria-labelledby'?: string;
|
'aria-labelledby'?: string;
|
||||||
'aria-disabled'?: boolean;
|
'aria-disabled'?: boolean;
|
||||||
'aria-readonly'?: 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;
|
style: React.CSSProperties;
|
||||||
};
|
};
|
||||||
/** Imperatively set the value (snapped + clamped). */
|
/** Imperatively set the value (snapped + clamped). */
|
||||||
|
|
@ -50,18 +60,21 @@ interface DragSession {
|
||||||
lastY: number;
|
lastY: number;
|
||||||
/** Continuous normalized position, kept un-snapped for smooth relative drags. */
|
/** Continuous normalized position, kept un-snapped for smooth relative drags. */
|
||||||
n: number;
|
n: number;
|
||||||
|
/** Value at gesture start, restored when Escape cancels the drag. */
|
||||||
|
startValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKnob(props: KnobCoreProps): UseKnobResult {
|
export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
const {
|
const {
|
||||||
min = 0,
|
min: rawMin = 0,
|
||||||
max = 100,
|
max: rawMax = 100,
|
||||||
step = 0,
|
step = 0,
|
||||||
values,
|
values,
|
||||||
steps,
|
steps,
|
||||||
taper = linearTaper,
|
taper = linearTaper,
|
||||||
interaction = 'rotary',
|
interaction = 'rotary',
|
||||||
dragSensitivity = 200,
|
dragSensitivity = 200,
|
||||||
|
trackInset = 0,
|
||||||
fineMultiplier = 0.1,
|
fineMultiplier = 0.1,
|
||||||
angleOffset = 225,
|
angleOffset = 225,
|
||||||
angleRange = 270,
|
angleRange = 270,
|
||||||
|
|
@ -74,6 +87,11 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
getAriaValueText,
|
getAriaValueText,
|
||||||
} = props;
|
} = 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 decimals = props.decimals ?? (step > 0 ? decimalsFromStep(step) : 3);
|
||||||
const doubleClickReset =
|
const doubleClickReset =
|
||||||
props.doubleClickReset ?? props.defaultValue !== undefined;
|
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. */
|
/** Clamp, snap and round a raw value into an emittable one. */
|
||||||
const constrain = React.useCallback(
|
const constrain = React.useCallback(
|
||||||
(raw: number): number => {
|
(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) {
|
if (values && values.length > 0) {
|
||||||
v = findClosest(values, v);
|
v = findClosest(values, v);
|
||||||
} else if (steps && steps > 1) {
|
} else if (steps && steps > 1) {
|
||||||
|
|
@ -90,7 +108,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
const snapped = Math.round(n * (steps - 1)) / (steps - 1);
|
const snapped = Math.round(n * (steps - 1)) / (steps - 1);
|
||||||
v = taper.fromNormalized(snapped, min, max);
|
v = taper.fromNormalized(snapped, min, max);
|
||||||
} else if (step > 0) {
|
} 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);
|
return roundTo(v, decimals);
|
||||||
},
|
},
|
||||||
|
|
@ -103,16 +121,28 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
);
|
);
|
||||||
const value = constrain(isControlled ? (props.value as number) : internalValue);
|
const value = constrain(isControlled ? (props.value as number) : internalValue);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const [isFocusVisible, setIsFocusVisible] = React.useState(false);
|
||||||
|
|
||||||
const valueRef = React.useRef(value);
|
const valueRef = React.useRef(value);
|
||||||
valueRef.current = 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(
|
const emit = React.useCallback(
|
||||||
(raw: number) => {
|
(raw: number) => {
|
||||||
const next = constrain(raw);
|
const next = constrain(raw);
|
||||||
if (next === valueRef.current) return;
|
if (next === valueRef.current) return;
|
||||||
valueRef.current = next;
|
// Only pre-commit in uncontrolled mode. In controlled mode the parent
|
||||||
if (!isControlled) setInternalValue(next);
|
// 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);
|
onChange?.(next);
|
||||||
},
|
},
|
||||||
[constrain, isControlled, onChange],
|
[constrain, isControlled, onChange],
|
||||||
|
|
@ -140,11 +170,15 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'track-vertical': {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'track-horizontal': {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -161,14 +195,18 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
s.lastX = clientX;
|
s.lastX = clientX;
|
||||||
s.lastY = clientY;
|
s.lastY = clientY;
|
||||||
},
|
},
|
||||||
[interaction, angleOffset, angleRange, dragSensitivity, fineMultiplier, setFromNormalized],
|
[interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromNormalized],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerDown = React.useCallback(
|
const onPointerDown = React.useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (!interactive || !ref.current) return;
|
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;
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
pointerFocus.current = true;
|
||||||
ref.current.focus({ preventScroll: true });
|
ref.current.focus({ preventScroll: true });
|
||||||
ref.current.setPointerCapture(e.pointerId);
|
ref.current.setPointerCapture(e.pointerId);
|
||||||
const rect = ref.current.getBoundingClientRect();
|
const rect = ref.current.getBoundingClientRect();
|
||||||
|
|
@ -180,52 +218,67 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
lastX: e.clientX,
|
lastX: e.clientX,
|
||||||
lastY: e.clientY,
|
lastY: e.clientY,
|
||||||
n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1),
|
n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1),
|
||||||
|
startValue: valueRef.current,
|
||||||
};
|
};
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
onChangeStart?.(valueRef.current);
|
onChangeStartRef.current?.(valueRef.current);
|
||||||
// Fader-style modes jump to the pressed position immediately.
|
// Fader-style modes jump to the pressed position immediately.
|
||||||
if (interaction === 'track-vertical' || interaction === 'track-horizontal') {
|
if (interaction === 'track-vertical' || interaction === 'track-horizontal') {
|
||||||
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[interactive, interaction, taper, min, max, onChangeStart, applyPointer],
|
[interactive, interaction, taper, min, max, applyPointer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerMove = React.useCallback(
|
const onPointerMove = React.useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
|
if (!interactive) return;
|
||||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||||
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
||||||
},
|
},
|
||||||
[applyPointer],
|
[interactive, applyPointer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const endDrag = React.useCallback(
|
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
|
||||||
(e: React.PointerEvent) => {
|
// otherwise it would keep emitting while rendered as disabled.
|
||||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
React.useEffect(() => {
|
||||||
session.current = null;
|
if (interactive || !session.current) return;
|
||||||
setIsDragging(false);
|
session.current = null;
|
||||||
onChangeEnd?.(valueRef.current);
|
setIsDragging(false);
|
||||||
},
|
onChangeEndRef.current?.(valueRef.current);
|
||||||
[onChangeEnd],
|
}, [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.
|
// Wheel — needs a non-passive listener so preventDefault stops page scroll.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const wheelSettle = React.useRef<ReturnType<typeof setTimeout>>();
|
const wheelSettle = React.useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const wheelActive = React.useRef(false);
|
||||||
const nudge = React.useCallback(
|
const nudge = React.useCallback(
|
||||||
(direction: number, multiplier = 1) => {
|
(direction: number, multiplier = 1) => {
|
||||||
const current = valueRef.current;
|
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) {
|
if (values && values.length > 0) {
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
const idx = sorted.indexOf(findClosest(sorted, current));
|
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) {
|
} else if (steps && steps > 1) {
|
||||||
const n = taper.toNormalized(current, min, max);
|
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 {
|
} else {
|
||||||
const base = step > 0 ? step : (max - min) / 100;
|
emit(current + direction * ((max - min) / 100) * multiplier);
|
||||||
emit(current + direction * base * multiplier);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[values, steps, step, min, max, taper, emit, setFromNormalized],
|
[values, steps, step, min, max, taper, emit, setFromNormalized],
|
||||||
|
|
@ -238,17 +291,37 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
if (!el || !enableWheel || !interactive) return;
|
if (!el || !enableWheel || !interactive) return;
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
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);
|
nudgeRef.current(direction, e.shiftKey ? fineMultiplier : 1);
|
||||||
clearTimeout(wheelSettle.current);
|
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 });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('wheel', onWheel);
|
el.removeEventListener('wheel', onWheel);
|
||||||
clearTimeout(wheelSettle.current);
|
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
|
// Keyboard
|
||||||
|
|
@ -257,6 +330,17 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
const onKeyDown = React.useCallback(
|
const onKeyDown = React.useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (!interactive) return;
|
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;
|
const span = max - min;
|
||||||
let handled = true;
|
let handled = true;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
|
|
@ -294,15 +378,34 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
const onKeyUp = React.useCallback(() => {
|
const onKeyUp = React.useCallback(() => {
|
||||||
if (keyAdjusted.current) {
|
if (keyAdjusted.current) {
|
||||||
keyAdjusted.current = false;
|
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(() => {
|
const onDoubleClick = React.useCallback(() => {
|
||||||
if (!interactive || !doubleClickReset || props.defaultValue === undefined) return;
|
if (!interactive || !doubleClickReset || props.defaultValue === undefined) return;
|
||||||
emit(props.defaultValue);
|
const target = constrain(props.defaultValue);
|
||||||
onChangeEnd?.(valueRef.current);
|
onChangeStartRef.current?.(valueRef.current);
|
||||||
}, [interactive, doubleClickReset, props.defaultValue, emit, onChangeEnd]);
|
emit(target);
|
||||||
|
onChangeEndRef.current?.(target);
|
||||||
|
}, [interactive, doubleClickReset, props.defaultValue, emit, constrain]);
|
||||||
|
|
||||||
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
||||||
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
||||||
|
|
@ -312,6 +415,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
normalized,
|
normalized,
|
||||||
angle,
|
angle,
|
||||||
isDragging,
|
isDragging,
|
||||||
|
isFocusVisible,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
decimals,
|
decimals,
|
||||||
|
|
@ -327,22 +431,41 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
onPointerCancel: endDrag,
|
onPointerCancel: endDrag,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
role: 'slider',
|
role: 'slider',
|
||||||
tabIndex: disabled ? -1 : 0,
|
tabIndex: disabled ? -1 : 0,
|
||||||
'aria-valuemin': min,
|
'aria-valuemin': min,
|
||||||
'aria-valuemax': max,
|
'aria-valuemax': max,
|
||||||
'aria-valuenow': value,
|
'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-valuetext': getAriaValueText ? getAriaValueText(value) : undefined,
|
||||||
'aria-label': props['aria-label'],
|
'aria-label': props['aria-label'],
|
||||||
'aria-labelledby': props['aria-labelledby'],
|
'aria-labelledby': props['aria-labelledby'],
|
||||||
'aria-disabled': disabled || undefined,
|
'aria-disabled': disabled || undefined,
|
||||||
'aria-readonly': readOnly || undefined,
|
'aria-readonly': readOnly || undefined,
|
||||||
|
'data-dragging': isDragging ? '' : undefined,
|
||||||
|
'data-disabled': disabled ? '' : undefined,
|
||||||
|
'data-readonly': readOnly ? '' : undefined,
|
||||||
|
'data-focus-visible': isFocusVisible ? '' : undefined,
|
||||||
style: {
|
style: {
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitUserSelect: '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',
|
outline: 'none',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export {
|
||||||
export { Arc, type ArcProps } from './primitives/Arc';
|
export { Arc, type ArcProps } from './primitives/Arc';
|
||||||
export { Pointer, type PointerProps } from './primitives/Pointer';
|
export { Pointer, type PointerProps } from './primitives/Pointer';
|
||||||
export { Ticks, type TicksProps } from './primitives/Ticks';
|
export { Ticks, type TicksProps } from './primitives/Ticks';
|
||||||
|
export { TickLabels, type TickLabelsProps } from './primitives/TickLabels';
|
||||||
export { Face, type FaceProps } from './primitives/Face';
|
export { Face, type FaceProps } from './primitives/Face';
|
||||||
export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from './primitives/Text';
|
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'> {
|
export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
/** Keyboard focus ring color, or false to disable. */
|
||||||
|
focusRing?: string | false;
|
||||||
/** Travel length in px. Default: 160. */
|
/** Travel length in px. Default: 160. */
|
||||||
length?: number;
|
length?: number;
|
||||||
/** Cross-axis size in px. Default: 44. */
|
/** 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. */
|
/** Studio channel fader: absolute-position linear control with a cap handle. */
|
||||||
export const Fader: React.FC<FaderProps> = ({
|
export const Fader: React.FC<FaderProps> = ({
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
|
focusRing = 'rgba(130,180,255,0.65)',
|
||||||
length = 160,
|
length = 160,
|
||||||
breadth = 44,
|
breadth = 44,
|
||||||
color = '#4cc2ff',
|
color = '#4cc2ff',
|
||||||
|
|
@ -46,13 +49,16 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
const vertical = orientation === 'vertical';
|
const vertical = orientation === 'vertical';
|
||||||
|
const capMain = 20; // handle size along the travel axis
|
||||||
const knob = useKnob({
|
const knob = useKnob({
|
||||||
...core,
|
...core,
|
||||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
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 id = React.useId();
|
||||||
|
|
||||||
const capMain = 20; // handle size along the travel axis
|
|
||||||
const capCross = breadth * 0.62;
|
const capCross = breadth * 0.62;
|
||||||
const trackW = Math.max(4, breadth * 0.12);
|
const trackW = Math.max(4, breadth * 0.12);
|
||||||
const w = vertical ? breadth : length;
|
const w = vertical ? breadth : length;
|
||||||
|
|
@ -118,7 +124,19 @@ export const Fader: React.FC<FaderProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2={vertical ? '0' : '1'} y2={vertical ? '1' : '0'}>
|
<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;
|
glow?: boolean;
|
||||||
/** Well/panel color behind the LEDs. */
|
/** Well/panel color behind the LEDs. */
|
||||||
faceColor?: string;
|
faceColor?: string;
|
||||||
|
/** Keyboard focus ring color, or false to disable. */
|
||||||
|
focusRing?: string | false;
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Seven-segment readout above the bar. Default: true. */
|
/** Seven-segment readout above the bar. Default: true. */
|
||||||
showValue?: boolean;
|
showValue?: boolean;
|
||||||
|
|
@ -53,6 +55,7 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
offOpacity = 0.13,
|
offOpacity = 0.13,
|
||||||
glow = true,
|
glow = true,
|
||||||
faceColor = '#0b0d0e',
|
faceColor = '#0b0d0e',
|
||||||
|
focusRing = 'rgba(130,180,255,0.65)',
|
||||||
label,
|
label,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
digits = 4,
|
digits = 4,
|
||||||
|
|
@ -63,17 +66,21 @@ export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||||
...core
|
...core
|
||||||
}) => {
|
}) => {
|
||||||
const vertical = orientation === 'vertical';
|
const vertical = orientation === 'vertical';
|
||||||
|
const pad = 5;
|
||||||
const knob = useKnob({
|
const knob = useKnob({
|
||||||
...core,
|
...core,
|
||||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||||
|
trackInset: pad,
|
||||||
});
|
});
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const glowId = `${id}-glow`;
|
const glowId = `${id}-glow`;
|
||||||
|
|
||||||
const w = vertical ? breadth : length;
|
const w = vertical ? breadth : length;
|
||||||
const h = vertical ? length : breadth;
|
const h = vertical ? length : breadth;
|
||||||
const pad = 5;
|
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
||||||
const zoneList: readonly LEDFaderZone[] = zones ?? [{ upTo: 1, color }];
|
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
||||||
|
[zones, color],
|
||||||
|
);
|
||||||
const zoneFor = (t: number): string =>
|
const zoneFor = (t: number): string =>
|
||||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
(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>
|
||||||
)}
|
)}
|
||||||
<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">
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<filter
|
<filter
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,19 @@ import { Knob } from '../components/Knob';
|
||||||
import { Arc } from '../primitives/Arc';
|
import { Arc } from '../primitives/Arc';
|
||||||
import { Pointer } from '../primitives/Pointer';
|
import { Pointer } from '../primitives/Pointer';
|
||||||
import { Ticks } from '../primitives/Ticks';
|
import { Ticks } from '../primitives/Ticks';
|
||||||
import { KnobLabel } from '../primitives/Text';
|
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||||
import { UI_FONT, type SkinProps } from './shared';
|
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
||||||
|
|
||||||
export interface MetalKnobProps extends SkinProps {
|
export interface MetalKnobProps extends SkinProps {
|
||||||
/** Accent color for the active ticks / value arc. */
|
/** Accent color for the active ticks / value arc. */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Overall metal tone. 'silver' | 'dark' or any base hex for the cap. */
|
/** Overall metal tone. */
|
||||||
tone?: 'silver' | 'dark';
|
tone?: 'silver' | 'dark';
|
||||||
tickColor?: string;
|
tickColor?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
indicatorColor?: string;
|
indicatorColor?: string;
|
||||||
|
/** Readout color when `showValue` is set. Defaults to a contrast pick for the tone. */
|
||||||
|
textColor?: string;
|
||||||
showArc?: boolean;
|
showArc?: boolean;
|
||||||
tickCount?: number;
|
tickCount?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -26,9 +28,13 @@ export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||||
tickColor = 'rgba(255,255,255,0.18)',
|
tickColor = 'rgba(255,255,255,0.18)',
|
||||||
labelColor = 'rgba(255,255,255,0.45)',
|
labelColor = 'rgba(255,255,255,0.45)',
|
||||||
indicatorColor,
|
indicatorColor,
|
||||||
|
textColor,
|
||||||
showArc = true,
|
showArc = true,
|
||||||
tickCount = 21,
|
tickCount = 21,
|
||||||
label,
|
label,
|
||||||
|
showValue = false,
|
||||||
|
unit,
|
||||||
|
format,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...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" />
|
<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} />
|
<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 && (
|
{label && (
|
||||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
|
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
|
||||||
{label}
|
{label}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Knob } from '../components/Knob';
|
import { Knob } from '../components/Knob';
|
||||||
import { Ticks } from '../primitives/Ticks';
|
import { Ticks } from '../primitives/Ticks';
|
||||||
|
import { TickLabels } from '../primitives/TickLabels';
|
||||||
import { KnobLabel } from '../primitives/Text';
|
import { KnobLabel } from '../primitives/Text';
|
||||||
import { polarToCartesian } from '../core/math';
|
import { polarToCartesian } from '../core/math';
|
||||||
import { useKnobContext } from '../core/context';
|
import { useKnobContext } from '../core/context';
|
||||||
import { UI_FONT, type SkinProps } from './shared';
|
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'. */
|
/** Knob body color. Classic options: cream '#efe6d0' or bakelite '#26221f'. */
|
||||||
bodyColor?: string;
|
bodyColor?: string;
|
||||||
/** Tick/scale color. */
|
/** 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. */
|
/** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */
|
||||||
export const VintageKnob: React.FC<VintageKnobProps> = ({
|
export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||||
size = 96,
|
size = 96,
|
||||||
|
|
@ -140,7 +115,7 @@ export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||||
color={scaleColor}
|
color={scaleColor}
|
||||||
activeColor={color ?? scaleColor}
|
activeColor={color ?? scaleColor}
|
||||||
/>
|
/>
|
||||||
{scaleLabels && <ScaleLabels labels={scaleLabels} color={scaleColor} />}
|
{scaleLabels && <TickLabels labels={scaleLabels} color={scaleColor} fontFamily={UI_FONT} />}
|
||||||
<ChickenHead
|
<ChickenHead
|
||||||
bodyColor={bodyColor}
|
bodyColor={bodyColor}
|
||||||
indicatorColor={
|
indicatorColor={
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue