diff --git a/apps/docs/src/sections/Playground.tsx b/apps/docs/src/sections/Playground.tsx index 5bda4fa..14133e0 100644 --- a/apps/docs/src/sections/Playground.tsx +++ b/apps/docs/src/sections/Playground.tsx @@ -135,8 +135,10 @@ export const Playground: React.FC = () => { return ; case 'rubber': return ; - case 'vintage': - return ; + case 'vintage': { + const { showValue: _sv, unit: _u, ...vintageProps } = common; + return ; + } case 'led': return ; case 'neon': diff --git a/packages/dreamknob/src/components/Knob.tsx b/packages/dreamknob/src/components/Knob.tsx index 51facf5..ffc6d6f 100644 --- a/packages/dreamknob/src/components/Knob.tsx +++ b/packages/dreamknob/src/components/Knob.tsx @@ -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 = ({ size = 80, + focusRing = 'rgba(130,180,255,0.65)', className, style, children, @@ -52,6 +58,10 @@ export const Knob: React.FC = ({ position: 'relative', width: size, height: size, + borderRadius: '50%', + ...(knob.isFocusVisible && focusRing + ? { boxShadow: `0 0 0 2px ${focusRing}` } + : undefined), ...knob.bind.style, ...style, }} diff --git a/packages/dreamknob/src/core/math.test.ts b/packages/dreamknob/src/core/math.test.ts index 1adf2e0..9fdb1b0 100644 --- a/packages/dreamknob/src/core/math.test.ts +++ b/packages/dreamknob/src/core/math.test.ts @@ -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); diff --git a/packages/dreamknob/src/core/math.ts b/packages/dreamknob/src/core/math.ts index 80bf49a..a6ef03f 100644 --- a/packages/dreamknob/src/core/math.ts +++ b/packages/dreamknob/src/core/math.ts @@ -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); + }, }; /** diff --git a/packages/dreamknob/src/core/types.ts b/packages/dreamknob/src/core/types.ts index b0df137..cdb1584 100644 --- a/packages/dreamknob/src/core/types.ts +++ b/packages/dreamknob/src/core/types.ts @@ -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. */ diff --git a/packages/dreamknob/src/digital/segments.tsx b/packages/dreamknob/src/digital/segments.tsx index 1b26a57..89356fb 100644 --- a/packages/dreamknob/src/digital/segments.tsx +++ b/packages/dreamknob/src/digital/segments.tsx @@ -22,9 +22,41 @@ const CHAR_SEGMENTS: Record = { '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), ' '); }; diff --git a/packages/dreamknob/src/hooks/useKnob.ts b/packages/dreamknob/src/hooks/useKnob.ts index 30c01a1..7a88809 100644 --- a/packages/dreamknob/src/hooks/useKnob.ts +++ b/packages/dreamknob/src/hooks/useKnob.ts @@ -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; /** 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>(); + 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', }, }, diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts index bc05e2a..480da08 100644 --- a/packages/dreamknob/src/index.ts +++ b/packages/dreamknob/src/index.ts @@ -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'; diff --git a/packages/dreamknob/src/primitives/TickLabels.tsx b/packages/dreamknob/src/primitives/TickLabels.tsx new file mode 100644 index 0000000..8d2734f --- /dev/null +++ b/packages/dreamknob/src/primitives/TickLabels.tsx @@ -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 | undefined; +} + +/** Printed scale labels around the knob's travel (0…10 amp-style scales). */ +export const TickLabels: React.FC = ({ + labels, + radius, + fontSize, + color = 'currentColor', + fontFamily, + getLabelProps, +}) => { + const { size, center: c, angleOffset, angleRange } = useKnobContext(); + const r = radius ?? size / 2 - size * 0.02; + return ( + + {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} + + ); + })} + + ); +}; diff --git a/packages/dreamknob/src/skins/Fader.tsx b/packages/dreamknob/src/skins/Fader.tsx index 69d3903..18b9c86 100644 --- a/packages/dreamknob/src/skins/Fader.tsx +++ b/packages/dreamknob/src/skins/Fader.tsx @@ -5,6 +5,8 @@ import { MONO_FONT, UI_FONT } from './shared'; export interface FaderProps extends Omit { 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 { /** Studio channel fader: absolute-position linear control with a cap handle. */ export const Fader: React.FC = ({ orientation = 'vertical', + focusRing = 'rgba(130,180,255,0.65)', length = 160, breadth = 44, color = '#4cc2ff', @@ -46,13 +49,16 @@ export const Fader: React.FC = ({ ...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 = ({ )} -
+
)} -
+