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

@ -0,0 +1,181 @@
import * as React from 'react';
import { clamp, linearTaper } from '../core/math';
import type { Taper } from '../core/math';
import { useKnobTheme } from '../core/theme';
import type { LEDFaderZone } from './LEDFader';
import { SegmentDisplay } from '../digital/SegmentDisplay';
export interface MeterProps {
/** Level to display. The meter is read-only — always controlled. */
value: number;
min?: number;
max?: number;
taper?: Taper;
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/** Cross-axis size in px. Default: 16. */
breadth?: number;
/** Number of LED segments. Default: 28. */
segments?: number;
/** LED color when no zones are given. */
color?: string;
/** Meter zones, e.g. green / yellow / red by normalized position. */
zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments. Default: 0.1. */
offOpacity?: number;
/**
* Peak hold time in ms the highest recent segment stays lit and falls
* back after this long. `false` disables. Default: 1200.
*/
peakHold?: number | false;
/** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string;
/** Panel color behind the LEDs. */
faceColor?: string;
label?: string;
/** Show a seven-segment readout. Default: false. */
showValue?: boolean;
digits?: number;
displayDecimals?: number;
labelColor?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* Read-only LED level meter with peak hold the display-side sibling of
* LEDFader for VU/output metering.
*/
export const Meter: React.FC<MeterProps> = ({
value,
min = 0,
max = 100,
taper = linearTaper,
orientation = 'vertical',
length = 160,
breadth = 16,
segments = 28,
color = '#3df2ad',
zones,
offOpacity = 0.1,
peakHold = 1200,
peakColor,
faceColor = '#0b0d0e',
label,
showValue = false,
digits = 4,
displayDecimals = 1,
labelColor,
className,
style,
...aria
}) => {
const theme = useKnobTheme();
const vertical = orientation === 'vertical';
const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1);
// Peak: track the highest recent level, fall back after `peakHold` ms.
const [peak, setPeak] = React.useState(n);
React.useEffect(() => {
if (peakHold === false) return;
if (n >= peak) {
setPeak(n);
return;
}
const t = setTimeout(() => setPeak(n), peakHold);
return () => clearTimeout(t);
}, [n, peak, peakHold]);
const pad = 4;
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
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;
const lit = Math.round(n * segments);
const peakIdx =
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
const slot = (length - pad * 2) / segments;
const gap = Math.min(2.5, slot * 0.35);
const cross = breadth - pad * 2;
const leds: React.ReactNode[] = [];
for (let i = 0; i < segments; i++) {
const zone = zoneFor((i + 1) / segments);
const isPeak = i === peakIdx && i >= lit;
const on = i < lit || isPeak;
const fill = isPeak ? (peakColor ?? zone) : zone;
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
leds.push(
vertical ? (
<rect
key={i}
x={pad}
y={length - pad - (i + 1) * slot + gap / 2}
width={cross}
height={slot - gap}
{...common}
/>
) : (
<rect key={i} x={pad + i * slot + gap / 2} y={pad} width={slot - gap} height={cross} {...common} />
),
);
}
return (
<div
className={className}
role="meter"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={clamp(value, min, max)}
aria-label={aria['aria-label']}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 5,
width: w,
...style,
}}
>
{showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<SegmentDisplay
value={clamp(value, min, max)}
digits={digits}
decimals={displayDecimals}
height={11}
color={zoneFor(n)}
background="none"
ghostOpacity={0.06}
/>
</div>
)}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
{leds}
</svg>
{label && (
<span
style={{
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: labelColor ?? theme.label,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
)}
</div>
);
};