DreamKnob/packages/dreamknob/src/components/Knob.tsx
Dreamodus 804a85cca6 Tier 1: origin anchor, detents, theming, type-in editing, ImageKnob
- origin: bipolar anchor value — Arc from=center, Fader fill and
  LEDFader segments draw from origin instead of min/mid-travel
- detents + detentSize: magnetic snap while dragging (normalized
  space, wheel/keyboard unaffected); applyDetents exported
- DreamknobProvider + KnobTheme: accent/track/face/text/label/ticks/
  focusRing/fonts tokens with dark and light bases; every skin
  resolves defaults from the theme, instance props win
- editable + parseValue: double-click knobs (or click fader readouts)
  to type exact values; Enter/blur commits, Esc cancels, 1.2k parses;
  ValueInput + defaultParseValue exported; setValue added to context
- ImageKnob: film-strip sprite knob (KnobMan-style strips), vertical
  or horizontal, with focus ring, editing, value/label
- Docs: LED gallery cards for film-strip/bipolar/editing/theming,
  playground editable toggle, theming API section, README updates
2026-07-12 15:05:46 -07:00

120 lines
3.7 KiB
TypeScript

import * as React from 'react';
import { KnobContextProvider, type KnobRenderContext } from '../core/context';
import { useKnobTheme } from '../core/theme';
import type { KnobCoreProps } from '../core/types';
import { useKnob } from '../hooks/useKnob';
import { ValueInput, defaultParseValue } from './ValueInput';
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: the theme's ring.
*/
focusRing?: string | false;
/**
* Double-click opens a type-in editor instead of resetting: type an exact
* value, Enter/blur commits, Escape cancels. Accepts "1.2k" style input.
*/
editable?: boolean;
/** Custom parser for typed input, e.g. to accept "-6 dB" or "A4". */
parseValue?: (text: string) => number | null;
className?: string;
style?: React.CSSProperties;
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
children?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
/** Extra content rendered inside the wrapper but outside the SVG (e.g. HTML labels). */
overlay?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
}
/**
* Headless-but-visual knob container: wires up the full interaction model
* (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y)
* and provides a render context for composable SVG primitives.
*/
export const Knob: React.FC<KnobProps> = ({
size = 80,
focusRing,
editable = false,
parseValue,
className,
style,
children,
overlay,
...core
}) => {
const theme = useKnobTheme();
const ring = focusRing ?? theme.focusRing;
const knob = useKnob(core);
const [editing, setEditing] = React.useState(false);
const closeEditor = () => {
setEditing(false);
knob.ref.current?.focus({ preventScroll: true });
};
const onDoubleClick = editable
? (e: React.MouseEvent) => {
e.stopPropagation();
setEditing(true);
}
: knob.bind.onDoubleClick;
const ctx: KnobRenderContext = {
value: knob.value,
normalized: knob.normalized,
originNormalized: knob.originNormalized,
angle: knob.angle,
isDragging: knob.isDragging,
min: knob.min,
max: knob.max,
decimals: knob.decimals,
angleOffset: knob.angleOffset,
angleRange: knob.angleRange,
size,
center: size / 2,
setValue: knob.setValue,
};
return (
<KnobContextProvider value={ctx}>
<div
{...knob.bind}
onDoubleClick={onDoubleClick}
className={className}
style={{
display: 'inline-flex',
position: 'relative',
width: size,
height: size,
borderRadius: '50%',
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
...knob.bind.style,
...style,
}}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
style={{ display: 'block', overflow: 'visible' }}
aria-hidden="true"
>
{typeof children === 'function' ? children(ctx) : children}
</svg>
{typeof overlay === 'function' ? overlay(ctx) : overlay}
{editing && (
<ValueInput
initial={String(knob.value)}
onCommit={raw => {
const parsed = (parseValue ?? defaultParseValue)(raw);
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
closeEditor();
}}
onCancel={closeEditor}
/>
)}
</div>
</KnobContextProvider>
);
};