Tier 2: drag feel, wheel overhaul, refs/meta/forms, Meter, gradients

- rotaryMode: 'absolute' | 'relative' (angle deltas, no grab jump) |
  'pickup' (MIDI-style: engages when the pointer sweeps past the value)
- dragAcceleration: velocity gain for relative drags (Shift bypasses);
  hideCursorOnDrag hides the cursor mid-gesture
- Wheel: trackpad delta accumulation (flicks can't rocket the value,
  one notch per wheel click), wheelStep override, wheelRequiresFocus
  opt-out of the hover scroll-trap
- onChange/onChangeStart/onChangeEnd now receive { source: 'drag' |
  'wheel' | 'keyboard' | 'reset' | 'api' }
- Every component forwards a ref to its root; name prop renders a
  hidden form input; SVG primitives carry data-part attributes
- Arc gradient: position-anchored color stops rendered as sliced arcs
  (mixColors/sampleGradient exported, unit-tested)
- New read-only Meter: LED level meter with peak hold, zones, readout
- Fader capColor/capLength for custom handle styling
- Docs: Meter/gradient/drag-feel gallery cards, white-cap fader demo,
  API rows, README
This commit is contained in:
Dreamodus 2026-07-12 15:18:54 -07:00
parent 804a85cca6
commit 0aee48b749
27 changed files with 756 additions and 134 deletions

View file

@ -1,6 +1,7 @@
import * as React from 'react';
import { useKnobContext } from '../core/context';
import { describeArc } from '../core/math';
import { sampleGradient, type GradientStop } from '../core/colors';
export interface ArcProps {
/** Arc radius. Defaults to (size - thickness) / 2. */
@ -8,6 +9,13 @@ export interface ArcProps {
thickness?: number;
/** Value arc color. */
color?: string;
/**
* Position-anchored gradient for the value arc, e.g. greenyellowred:
* `[{at: 0, color: '#3df2ad'}, {at: 0.8, color: '#ffd23e'}, {at: 1, color: '#ff4d6b'}]`.
* Colors stay fixed in space (the red zone is always at the top of travel).
* Overrides `color`.
*/
gradient?: readonly GradientStop[];
/** Background track color. Omit to skip the track. */
trackColor?: string;
/**
@ -27,6 +35,7 @@ export const Arc: React.FC<ArcProps> = ({
radius,
thickness = 4,
color = 'currentColor',
gradient,
trackColor,
from = 'min',
cap = 'round',
@ -49,19 +58,39 @@ export const Arc: React.FC<ArcProps> = ({
end = angleOffset + normalized * angleRange;
}
return (
<g opacity={opacity}>
{trackColor && (
<path
d={describeArc(center, center, r, angleOffset, angleOffset + angleRange)}
stroke={trackColor}
strokeWidth={thickness}
strokeLinecap={cap}
fill="none"
/>
)}
{end - start > 0.0001 && (
let valueArc: React.ReactNode = null;
if (end - start > 0.0001) {
if (gradient && gradient.length > 0) {
// SVG can't run a gradient along a path, so approximate with short
// butt-capped slices sampled from the gradient at their absolute
// position across the travel.
const sweep = end - start;
const slices = Math.max(2, Math.ceil(sweep / 4));
const parts: React.ReactNode[] = [];
for (let i = 0; i < slices; i++) {
const a0 = start + (i / slices) * sweep;
const a1 = start + ((i + 1) / slices) * sweep + (i < slices - 1 ? 0.35 : 0);
const mid = (a0 + a1) / 2;
parts.push(
<path
key={i}
d={describeArc(center, center, r, a0, a1)}
stroke={sampleGradient(gradient, (mid - angleOffset) / angleRange)}
strokeWidth={thickness}
strokeLinecap="butt"
fill="none"
/>,
);
}
valueArc = (
<g data-part="arc" {...(arcProps as React.SVGProps<SVGGElement>)}>
{parts}
</g>
);
} else {
valueArc = (
<path
data-part="arc"
d={describeArc(center, center, r, start, end)}
stroke={color}
strokeWidth={thickness}
@ -69,7 +98,23 @@ export const Arc: React.FC<ArcProps> = ({
fill="none"
{...arcProps}
/>
);
}
}
return (
<g opacity={opacity}>
{trackColor && (
<path
data-part="track"
d={describeArc(center, center, r, angleOffset, angleOffset + angleRange)}
stroke={trackColor}
strokeWidth={thickness}
strokeLinecap={cap}
fill="none"
/>
)}
{valueArc}
</g>
);
};

View file

@ -9,5 +9,7 @@ export interface FaceProps extends React.SVGProps<SVGCircleElement> {
/** The knob body: a centered circle. Style via fill/stroke/filter props. */
export const Face: React.FC<FaceProps> = ({ radius, ...rest }) => {
const { size, center } = useKnobContext();
return <circle cx={center} cy={center} r={radius ?? size / 2 - 6} {...rest} />;
return (
<circle data-part="face" cx={center} cy={center} r={radius ?? size / 2 - 6} {...rest} />
);
};

View file

@ -69,5 +69,9 @@ export const Pointer: React.FC<PointerProps> = ({
}
}
return <g transform={`rotate(${angle} ${center} ${center})`}>{shape}</g>;
return (
<g data-part="pointer" transform={`rotate(${angle} ${center} ${center})`}>
{shape}
</g>
);
};

View file

@ -42,6 +42,7 @@ export const KnobValue: React.FC<KnobValueProps> = ({
}${unit ?? ''}`;
return (
<text
data-part="value"
x={ctx.center}
y={ctx.center + dy}
textAnchor="middle"
@ -80,6 +81,7 @@ export const KnobLabel: React.FC<KnobLabelProps> = ({
const { size, center } = useKnobContext();
return (
<text
data-part="label"
x={center}
y={center + (dy ?? size * 0.38)}
textAnchor="middle"

View file

@ -26,7 +26,7 @@ export const TickLabels: React.FC<TickLabelsProps> = ({
const { size, center: c, angleOffset, angleRange } = useKnobContext();
const r = radius ?? size / 2 - size * 0.02;
return (
<g>
<g data-part="tick-labels">
{labels.map((text, i) => {
if (!text) return null;
const t = labels.length === 1 ? 0 : i / (labels.length - 1);

View file

@ -51,5 +51,5 @@ export const Ticks: React.FC<TicksProps> = ({
/>,
);
}
return <g>{ticks}</g>;
return <g data-part="ticks">{ticks}</g>;
};