Docs/playground accuracy (from a full docs-vs-source audit): - Playground: drag-feel select covers pickup/relative rotary modes, is disabled for faders, and generated code no longer emits props the target component does not accept - API/README claims corrected: Shift-fine wheel scope, wheelStep and decimals defaults, trackInset row, data-part and ref-forwarding scope, VintageKnob readout exception, Meter peakHold tag, stale component lists, aria-valuetext wording, also-exported list API consistency fixes surfaced by the audit and the tutorial: - Keyboard gestures now fire onChangeStart (bracket parity with drag/ wheel - fixes gesture-scoped undo patterns) - name (hidden form input) added to Fader, LEDFader, ImageKnob - PushButton gets a themed keyboard-only focus ring + data-focus-visible New docs content: - Getting Started: 7-step guide with live examples and a gotchas list - Advanced guide: 8-step channel-strip tutorial ending in a live, themed, gesture-undoable strip with meter (plus headless demo) - Synth section: playable Web Audio synth - osc/filter/env/LFO/master all dreamknob controls, momentary-pad keyboard, analyser-driven Meter
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
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 [focusVisible, setFocusVisible] = React.useState(false);
|
|
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}
|
|
data-focus-visible={focusVisible ? '' : undefined}
|
|
className={className}
|
|
onFocus={e => {
|
|
try {
|
|
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
|
} catch {
|
|
setFocusVisible(true);
|
|
}
|
|
}}
|
|
onBlur={() => setFocusVisible(false)}
|
|
{...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)',
|
|
outline: 'none',
|
|
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)'
|
|
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
|
|
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>
|
|
);
|
|
},
|
|
);
|