v1.1.0: integration-feedback release

Bugs from real-product integration:
- Meter/LEDFader clamp geometry below breadth 14 (no negative SVG rects)
- mixColors falls back to CSS color-mix() so theme tokens can be var()/oklch()
- type-in editing fires a real edit gesture (Start/onChange/End, source 'edit')
- ScrubField Enter/Escape no longer double-commit or commit-on-revert via blur
- disabled dims the whole control uniformly, data-disabled on the wrapper

New capabilities:
- commitMode 'release': drags preview, emit once on pointer-up, Esc discards
- Meter accepts value=[l, r, ...]; theme zoneGood/zoneWarn/zoneHot defaults
- XYPad per-axis invert + detents; Fader valuePosition top|end|none
- LED skins: unit labels, uncapped displayDecimals, aria-valuetext defaults

New components: IndicatorLamp, SegmentSwitch, ScrubField, MeterBridge,
PushButton led='dot'. Docs: change-contract table, recipes, gallery cards.
This commit is contained in:
Dreamodus 2026-07-13 12:07:52 -07:00
parent b3eaa604c2
commit d8b7b55505
31 changed files with 1471 additions and 223 deletions

View file

@ -0,0 +1,162 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface SegmentSwitchProps {
/** Position labels, e.g. ['LINEAR', 'RADIAL', 'CONIC']. */
options: readonly string[];
/** Controlled selected index. Pair with `onChange`. */
value?: number;
defaultValue?: number;
onChange?: (index: number) => void;
/** Accent for the active cell's LED tick and text. */
color?: string;
/** Control height in px. Default: 30. */
size?: number;
/** Show the LED tick on the active cell. Default: true. */
led?: boolean;
disabled?: boolean;
/** Render a hidden form input carrying the selected option text. */
name?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* Hardware-styled segmented selector the horizontal sibling of
* SteppedKnob. Renders a radiogroup; arrow keys move the selection.
*/
export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps>(
function SegmentSwitch(
{
options,
value,
defaultValue = 0,
onChange,
color,
size = 30,
led = true,
disabled = false,
name,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const isControlled = value !== undefined;
const [internal, setInternal] = React.useState(defaultValue);
const active = Math.max(0, Math.min(options.length - 1, isControlled ? (value as number) : internal));
const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = (i: number, focus = false) => {
const next = Math.max(0, Math.min(options.length - 1, i));
if (focus) btnRefs.current[next]?.focus();
if (next === active) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
select(active + 1, true);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
select(active - 1, true);
} else if (e.key === 'Home') {
e.preventDefault();
select(0, true);
} else if (e.key === 'End') {
e.preventDefault();
select(options.length - 1, true);
}
};
return (
<div
ref={ref}
role="radiogroup"
aria-label={aria['aria-label']}
data-disabled={disabled ? '' : undefined}
className={className}
onKeyDown={onKeyDown}
style={{
display: 'inline-flex',
height: size,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.7)',
background: 'linear-gradient(180deg, #16171c, #101116)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06), 0 2px 4px rgba(0,0,0,0.4)',
overflow: 'hidden',
opacity: disabled ? 0.45 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
...style,
}}
>
{options.map((opt, i) => {
const isActive = i === active;
return (
<button
key={opt}
ref={el => {
btnRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
disabled={disabled}
data-active={isActive ? '' : undefined}
onClick={() => select(i)}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
padding: '0 12px',
minWidth: size * 1.6,
border: 'none',
borderRight: i < options.length - 1 ? '1px solid rgba(0,0,0,0.55)' : 'none',
background: isActive
? 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
: 'transparent',
boxShadow: isActive ? 'inset 0 2px 4px rgba(0,0,0,0.55)' : undefined,
color: isActive ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.34),
fontWeight: 600,
letterSpacing: '0.07em',
textTransform: 'uppercase',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'background 80ms, color 80ms',
outline: 'none',
}}
>
{led && (
<span
aria-hidden="true"
style={{
width: '55%',
height: Math.max(2, size * 0.08),
borderRadius: 2,
background: isActive ? accent : 'rgba(255,255,255,0.08)',
boxShadow: isActive ? `0 0 ${size * 0.2}px ${accent}` : undefined,
transition: 'background 80ms, box-shadow 80ms',
}}
/>
)}
{opt}
</button>
);
})}
{name && <input type="hidden" name={name} value={options[active]} readOnly />}
</div>
);
},
);