DreamKnob/packages/dreamknob/src/skins/PushButton.tsx
Dreamodus 54189ad07a Docs accuracy pass, guides, and a playable Web Audio synth demo
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
2026-07-12 18:27:06 -07:00

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>
);
},
);