v1.1.2: action button, responsive faders, label scaling
Third integration-feedback release from building the streaming studio.
- New `Button` — a click-to-fire command button in the panel style, the
non-latching sibling of PushButton. Implemented as PushButton's new
`mode="action"` (never latches, press-down + brief LED flash, `onClick`);
led="dot" (default) matches the corner-dot look, led={false} is plain.
- `fill` on LEDFader (parity with Fader) — faders stretch to fill their
container in resizable panels instead of a fixed pixel length.
- `labelSize` on IndicatorLamp (and LampRow) — shrink the caption below its
10px floor to match dense header rows.
- Docs: a dedicated "Buttons & switches" section with a decision guide
(Button/PushButton/TransportButton/ToggleSwitch/SegmentSwitch) + live demos.
Library + docs typecheck and build; 38 tests pass.
This commit is contained in:
parent
89c7a7f603
commit
a6348d5c46
13 changed files with 372 additions and 27 deletions
|
|
@ -70,7 +70,7 @@ export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
|||
export { Meter, type MeterProps } from './skins/Meter';
|
||||
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
|
||||
export { Gauge, type GaugeProps } from './skins/Gauge';
|
||||
export { PushButton, type PushButtonProps } from './skins/PushButton';
|
||||
export { PushButton, type PushButtonProps, Button, type ButtonProps } from './skins/PushButton';
|
||||
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
|
||||
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
|
||||
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export interface IndicatorLampProps {
|
|||
readOnly?: boolean;
|
||||
/** Caption beside the lamp. */
|
||||
label?: string;
|
||||
/**
|
||||
* Caption font size in px, independent of the lamp diameter. Without it the
|
||||
* label tracks `size` but is floored at 10px; set this to shrink the caption
|
||||
* with a small lamp (e.g. matching a dense header row's height).
|
||||
*/
|
||||
labelSize?: number;
|
||||
labelPosition?: 'right' | 'left';
|
||||
disabled?: boolean;
|
||||
/** Render a hidden form input carrying "on"/"off". */
|
||||
|
|
@ -45,6 +51,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
|
|||
blink = false,
|
||||
readOnly = false,
|
||||
label,
|
||||
labelSize,
|
||||
labelPosition = 'right',
|
||||
disabled = false,
|
||||
name,
|
||||
|
|
@ -166,7 +173,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
|
|||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: Math.max(10, size * 0.9),
|
||||
fontSize: labelSize ?? Math.max(10, size * 0.9),
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
color: lit ? theme.text : theme.label,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
|||
orientation?: 'vertical' | 'horizontal';
|
||||
/** Travel length in px. Default: 160. */
|
||||
length?: number;
|
||||
/**
|
||||
* Stretch along the travel axis to fill the container (horizontal fills
|
||||
* width, vertical fills height) instead of a fixed `length` — the same
|
||||
* responsive behavior as `Fader`, so faders in resizable dock panels size
|
||||
* themselves instead of needing wrapper measurement.
|
||||
*/
|
||||
fill?: boolean;
|
||||
/** Cross-axis size in px. Default: 34. */
|
||||
breadth?: number;
|
||||
/** Number of LED segments. Default: 24. */
|
||||
|
|
@ -57,6 +64,7 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
|||
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
|
||||
orientation = 'vertical',
|
||||
length = 160,
|
||||
fill = false,
|
||||
breadth = 34,
|
||||
segments = 24,
|
||||
color: colorProp,
|
||||
|
|
@ -95,6 +103,12 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
|
||||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
// Fill stretches the travel axis (via preserveAspectRatio='none'); the cross
|
||||
// axis stays at `breadth`. The LED geometry is computed in the fixed viewBox
|
||||
// and scaled to the element — pointer mapping measures the real element, so
|
||||
// interaction stays correct at any rendered width.
|
||||
const ctrlW = fill && !vertical ? '100%' : w;
|
||||
const ctrlH = fill && vertical ? '100%' : h;
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
|
||||
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||
if (color) return [{ upTo: 1, color }];
|
||||
|
|
@ -157,7 +171,8 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
width: w,
|
||||
width: fill && !vertical ? '100%' : w,
|
||||
height: fill && vertical ? '100%' : undefined,
|
||||
opacity: core.disabled ? 0.45 : undefined,
|
||||
...style,
|
||||
}}
|
||||
|
|
@ -218,14 +233,21 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
style={{
|
||||
...knob.bind.style,
|
||||
opacity: undefined, // the outer wrapper owns disabled dimming
|
||||
width: w,
|
||||
height: h,
|
||||
display: 'inline-flex',
|
||||
width: ctrlW,
|
||||
height: ctrlH,
|
||||
display: fill ? 'flex' : 'inline-flex',
|
||||
borderRadius: 6,
|
||||
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||
}}
|
||||
>
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<svg
|
||||
width={ctrlW}
|
||||
height={ctrlH}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio={fill ? 'none' : undefined}
|
||||
style={{ display: 'block' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<filter
|
||||
id={glowId}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export interface LampRowProps {
|
|||
lamps: readonly LampRowItem[];
|
||||
/** Lamp diameter in px. Default: 9. */
|
||||
size?: number;
|
||||
/** Caption font size in px. Default: 9.5. */
|
||||
labelSize?: number;
|
||||
/** Caption placement relative to each lamp. Default: 'right'. */
|
||||
labelPosition?: 'right' | 'below';
|
||||
/** Make lamps clickable (a compact indicator toggle bank). */
|
||||
|
|
@ -31,6 +33,7 @@ export interface LampRowProps {
|
|||
export const LampRow: React.FC<LampRowProps> = ({
|
||||
lamps,
|
||||
size = 9,
|
||||
labelSize,
|
||||
labelPosition = 'right',
|
||||
onToggle,
|
||||
gap = 14,
|
||||
|
|
@ -80,7 +83,7 @@ export const LampRow: React.FC<LampRowProps> = ({
|
|||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 9.5,
|
||||
fontSize: labelSize ?? 9.5,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ export interface PushButtonProps {
|
|||
pressed?: boolean;
|
||||
defaultPressed?: boolean;
|
||||
onChange?: (pressed: boolean) => void;
|
||||
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
|
||||
mode?: 'toggle' | 'momentary';
|
||||
/**
|
||||
* 'toggle' latches; 'momentary' is on only while held; 'action' is a normal
|
||||
* click-to-fire command button — it never latches, shows a press-down + a
|
||||
* brief LED flash, and calls `onClick`. Default: 'toggle'.
|
||||
*/
|
||||
mode?: 'toggle' | 'momentary' | 'action';
|
||||
/** Fired on activation in `action` mode (click, Enter, or Space). */
|
||||
onClick?: () => void;
|
||||
/** LED / active color. */
|
||||
color?: string;
|
||||
/**
|
||||
|
|
@ -39,6 +45,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
pressed,
|
||||
defaultPressed = false,
|
||||
onChange,
|
||||
onClick,
|
||||
mode = 'toggle',
|
||||
color,
|
||||
led = true,
|
||||
|
|
@ -59,16 +66,42 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
const isControlled = pressed !== undefined;
|
||||
const [internal, setInternal] = React.useState(defaultPressed);
|
||||
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||
const on = isControlled ? (pressed as boolean) : internal;
|
||||
// Action mode is stateless: a transient `held` (pointer down) drives the
|
||||
// press-down look and a short `flash` lights the LED on activation.
|
||||
const [held, setHeld] = React.useState(false);
|
||||
const [flash, setFlash] = React.useState(false);
|
||||
const flashTimer = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
React.useEffect(() => () => clearTimeout(flashTimer.current), []);
|
||||
const latched = isControlled ? (pressed as boolean) : internal;
|
||||
const on = mode === 'action' ? held : latched; // press-down / background inset
|
||||
const ledOn = mode === 'action' ? held || flash : latched;
|
||||
|
||||
const set = (next: boolean) => {
|
||||
if (next === on) return;
|
||||
if (next === latched) return;
|
||||
if (!isControlled) setInternal(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const fire = () => {
|
||||
onClick?.();
|
||||
setFlash(true);
|
||||
clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setFlash(false), 160);
|
||||
};
|
||||
|
||||
const momentaryProps =
|
||||
mode === 'momentary'
|
||||
mode === 'action'
|
||||
? {
|
||||
onClick: fire,
|
||||
onPointerDown: (e: React.PointerEvent) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setHeld(true);
|
||||
},
|
||||
onPointerUp: () => setHeld(false),
|
||||
onPointerCancel: () => setHeld(false),
|
||||
onPointerLeave: () => setHeld(false),
|
||||
}
|
||||
: mode === 'momentary'
|
||||
? {
|
||||
onPointerDown: (e: React.PointerEvent) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
|
|
@ -83,14 +116,14 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
if (e.key === ' ' || e.key === 'Enter') set(false);
|
||||
},
|
||||
}
|
||||
: { onClick: () => set(!on) };
|
||||
: { onClick: () => set(!latched) };
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={on}
|
||||
aria-pressed={mode === 'action' ? undefined : latched}
|
||||
aria-label={aria['aria-label']}
|
||||
data-pressed={on ? '' : undefined}
|
||||
data-focus-visible={focusVisible ? '' : undefined}
|
||||
|
|
@ -148,8 +181,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
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)',
|
||||
background: ledOn ? accent : 'rgba(255,255,255,0.09)',
|
||||
boxShadow: ledOn ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
|
||||
transition: 'background 80ms, box-shadow 80ms',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -164,8 +197,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
width: Math.max(4, size * 0.14),
|
||||
height: Math.max(4, size * 0.14),
|
||||
borderRadius: '50%',
|
||||
background: on ? accent : 'rgba(255,255,255,0.09)',
|
||||
boxShadow: on ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
|
||||
background: ledOn ? accent : 'rgba(255,255,255,0.09)',
|
||||
boxShadow: ledOn ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
|
||||
transition: 'background 80ms, box-shadow 80ms',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -177,7 +210,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
color: on ? accent : theme.label,
|
||||
color: ledOn ? accent : theme.label,
|
||||
transition: 'color 80ms',
|
||||
}}
|
||||
>
|
||||
|
|
@ -187,8 +220,26 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
{children}
|
||||
</span>
|
||||
)}
|
||||
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
|
||||
{name && <input type="hidden" name={name} value={latched ? 'on' : 'off'} readOnly />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends Omit<PushButtonProps, 'mode' | 'pressed' | 'defaultPressed' | 'onChange'> {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A normal click-to-fire command button in the studio-panel style — the
|
||||
* non-latching sibling of `PushButton`. Use it for actions (Regenerate,
|
||||
* Add, Apply, Refresh). It's `PushButton` locked to `mode="action"`; pass
|
||||
* `led="dot"` for the corner-dot look (like a Fresh Seed button) or
|
||||
* `led={false}` for a plain button.
|
||||
*/
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button({ led = 'dot', ...rest }, ref) {
|
||||
return <PushButton ref={ref} mode="action" led={led} {...rest} />;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue