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:
parent
b3eaa604c2
commit
d8b7b55505
31 changed files with 1471 additions and 223 deletions
162
packages/dreamknob/src/skins/SegmentSwitch.tsx
Normal file
162
packages/dreamknob/src/skins/SegmentSwitch.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue