Tier 3: bubbles, animation, wrap mode, XYPad, AlphaDisplay, PushButton

- valueBubble/bubbleFormat: floating readout above knobs while dragging
  (Knob-based skins + ImageKnob)
- animateChanges: rAF ease-out tween of the pointer on programmatic
  value changes; user gestures snap; prefers-reduced-motion disables
- wrap: endless encoder mode — values and relative drags roll around
  min↔max; pairs with angleOffset={0} angleRange={360}
- XYPad: two-parameter pad with absolute drag, arrow keys (Shift
  coarse), Escape cancel, double-click reset, grid/crosshair, focus
  ring, hidden form inputs, change meta
- AlphaDisplay: fourteen-segment alphanumeric LED (A-Z, 0-9, symbols,
  decimal points, chars padding/align, glow/skew)
- PushButton: studio panel button with LED strip — toggle or momentary,
  controlled/uncontrolled, aria-pressed, hidden form input
- Docs: five new gallery cards incl. animated preset demo, API rows,
  README
This commit is contained in:
Dreamodus 2026-07-12 17:47:20 -07:00
parent 0aee48b749
commit e9e52e43ed
12 changed files with 936 additions and 8 deletions

View file

@ -0,0 +1,142 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface PushButtonProps {
/** Controlled pressed state. Pair with `onChange`. */
pressed?: boolean;
defaultPressed?: boolean;
onChange?: (pressed: boolean) => void;
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
mode?: 'toggle' | 'momentary';
/** LED / active color. */
color?: string;
/** Show the LED strip. Default: true. */
led?: boolean;
/** Button height in px. Default: 36. */
size?: number;
/** Explicit width; defaults to content. */
width?: number;
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
name?: string;
/** Caption, e.g. "MUTE". */
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
function PushButton(
{
pressed,
defaultPressed = false,
onChange,
mode = 'toggle',
color,
led = true,
size = 36,
width,
disabled = false,
name,
children,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed);
const on = isControlled ? (pressed as boolean) : internal;
const set = (next: boolean) => {
if (next === on) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
const momentaryProps =
mode === 'momentary'
? {
onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
set(true);
},
onPointerUp: () => set(false),
onPointerCancel: () => set(false),
onKeyDown: (e: React.KeyboardEvent) => {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) set(true);
},
onKeyUp: (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') set(false);
},
}
: { onClick: () => set(!on) };
return (
<button
ref={ref}
type="button"
disabled={disabled}
aria-pressed={on}
aria-label={aria['aria-label']}
data-pressed={on ? '' : undefined}
className={className}
{...momentaryProps}
style={{
position: 'relative',
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
height: size,
width,
minWidth: size * 1.4,
padding: '0 12px',
borderRadius: 7,
border: '1px solid rgba(0,0,0,0.7)',
background: on
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
boxShadow: on
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
color: on ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.28),
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.45 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
touchAction: 'manipulation',
transition: 'background 80ms, box-shadow 80ms, color 80ms',
...style,
}}
>
{led && (
<span
aria-hidden="true"
style={{
width: '55%',
height: Math.max(3, size * 0.09),
borderRadius: 3,
background: on ? accent : 'rgba(255,255,255,0.09)',
boxShadow: on ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms',
}}
/>
)}
{children}
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
</button>
);
},
);