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.
162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
},
|
|
);
|