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
|
|
@ -1,5 +1,47 @@
|
|||
# Changelog
|
||||
|
||||
## 1.1.0 — 2026-07-13
|
||||
|
||||
Integration-feedback release — everything in here came from wiring dreamknob into a
|
||||
real product.
|
||||
|
||||
### New components
|
||||
- `IndicatorLamp` — pressable LED lamp (`role="switch"`), toggle/momentary, `blink`
|
||||
(reduced-motion aware), `readOnly` status-light mode
|
||||
- `SegmentSwitch` — hardware-styled segmented selector (radiogroup, roving focus,
|
||||
arrow/Home/End keys, LED tick)
|
||||
- `ScrubField` — After-Effects-style numeric field: label scrubbing (Shift = fine),
|
||||
draft typing (Enter commits, Esc reverts), arrow steps, wheel; `role="spinbutton"`
|
||||
- `MeterBridge` — labeled multi-strip meter block with per-channel peak-hold text
|
||||
- `PushButton` gains `led="dot"` — corner-dot LED that scales down for small
|
||||
icon-only toggles (`led` now `boolean | 'strip' | 'dot'`)
|
||||
|
||||
### New capabilities
|
||||
- `commitMode="release"` — drags preview visually but emit a single `onChange` on
|
||||
pointer-up; Esc discards the preview (expensive parameters)
|
||||
- `Meter` accepts `value={[l, r, ...]}` for multi-channel bars with a shared clip LED
|
||||
- Theme gains `zoneGood` / `zoneWarn` / `zoneHot`; `Meter`, `MeterBridge` and
|
||||
`LEDFader` default their zones from the theme
|
||||
- `XYPad` axes accept `invert` and per-axis `detents`; arrow keys follow the visual
|
||||
direction on inverted axes
|
||||
- `Fader` gains `valuePosition: 'top' | 'end' | 'none'` — `'end'` renders one-line
|
||||
inspector rows with a stable readout width
|
||||
- `LEDKnob` / `LEDFader` / `Meter` gain `unit`; `displayDecimals` is no longer capped
|
||||
- Skins without visible readouts now default `aria-valuetext` from `format`/`unit`
|
||||
(`SteppedKnob` announces position names)
|
||||
|
||||
### Fixes
|
||||
- `Meter` clamps geometry below `breadth` 14 — no negative-dimension SVG rects on
|
||||
thin strips
|
||||
- `mixColors` falls back to CSS `color-mix()` for non-hex inputs, so theme tokens can
|
||||
be `var(--x)` / `oklch()` without breaking gradients and glows
|
||||
- Type-in editing now fires a proper edit gesture: `onChangeStart` → `onChange` →
|
||||
`onChangeEnd` with `source: 'edit'` (was a bare `'api'` set with no gesture events)
|
||||
- The `ChangeMeta.source` contract is documented (docs + `types.ts`): every user
|
||||
gesture brackets exactly one Start/End pair; `'api'` fires neither
|
||||
- Disabled controls set `data-disabled` on the wrapper (including labels/readouts)
|
||||
and dim uniformly
|
||||
|
||||
## 1.0.0 — 2026-07-12
|
||||
|
||||
Initial release.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
|
|||
Zero runtime dependencies.
|
||||
|
||||
```
|
||||
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz
|
||||
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz
|
||||
```
|
||||
|
||||
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
|
||||
|
|
@ -98,9 +98,13 @@ import {
|
|||
Fader, // linear channel fader, vertical or horizontal
|
||||
LEDFader, // segmented LED meter-fader with color zones
|
||||
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
||||
Meter, // read-only LED level meter with peak hold + latching clip LED
|
||||
Meter, // read-only LED level meter — mono or multi-channel (value={[l, r]})
|
||||
MeterBridge, // labeled multi-strip meter block with per-channel peak text
|
||||
XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
|
||||
PushButton, // panel button with LED strip (toggle or momentary)
|
||||
PushButton, // panel button with LED strip or corner dot (toggle or momentary)
|
||||
IndicatorLamp, // pressable LED lamp — the hardware checkbox (blink, momentary)
|
||||
SegmentSwitch, // segmented option selector (radiogroup, LED tick)
|
||||
ScrubField, // AE-style numeric field: drag the label to scrub, click to type
|
||||
SegmentDisplay, // standalone seven-segment numeric display
|
||||
AlphaDisplay, // fourteen-segment alphanumeric display
|
||||
} from 'dreamknob'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dreamknob",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
|
||||
"license": "MIT",
|
||||
"author": "Dreamodus Software Inc.",
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
|
|||
initial={String(knob.value)}
|
||||
onCommit={raw => {
|
||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||
closeEditor();
|
||||
}}
|
||||
onCancel={closeEditor}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ describe('mixColors', () => {
|
|||
it('expands shorthand hex', () => {
|
||||
expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000');
|
||||
});
|
||||
it('falls back to the nearer endpoint for non-hex input', () => {
|
||||
expect(mixColors('red', '#00ff00', 0.2)).toBe('red');
|
||||
expect(mixColors('red', '#00ff00', 0.8)).toBe('#00ff00');
|
||||
it('produces color-mix() for non-hex input (CSS vars, named colors)', () => {
|
||||
expect(mixColors('var(--accent)', '#00ff00', 0.25)).toBe(
|
||||
'color-mix(in srgb, #00ff00 25%, var(--accent))',
|
||||
);
|
||||
expect(mixColors('red', 'rgb(0, 255, 0)', 0.5)).toBe(
|
||||
'color-mix(in srgb, rgb(0, 255, 0) 50%, red)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,19 @@ const parseHex = (color: string): [number, number, number, number] | null => {
|
|||
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
|
||||
};
|
||||
|
||||
/** Mix two hex colors. Non-hex inputs fall back to the nearer endpoint. */
|
||||
/**
|
||||
* Mix two colors. Hex inputs are interpolated numerically; anything else
|
||||
* (var(--x), rgb(), named colors, color-mix() …) falls back to a CSS
|
||||
* `color-mix()` expression, which SVG fills/strokes accept in modern
|
||||
* browsers — so theme tokens may be any resolvable CSS color.
|
||||
*/
|
||||
export const mixColors = (a: string, b: string, t: number): string => {
|
||||
const ca = parseHex(a);
|
||||
const cb = parseHex(b);
|
||||
if (!ca || !cb) return t < 0.5 ? a : b;
|
||||
if (!ca || !cb) {
|
||||
const pct = Math.round(clamp01(t) * 1000) / 10;
|
||||
return `color-mix(in srgb, ${b} ${pct}%, ${a})`;
|
||||
}
|
||||
const lerp = (x: number, y: number) => Math.round(x + (y - x) * t);
|
||||
const [r, g, bl, al] = [0, 1, 2, 3].map(i => lerp(ca[i], cb[i]));
|
||||
const hex = (v: number) => v.toString(16).padStart(2, '0');
|
||||
|
|
@ -26,6 +34,8 @@ export const mixColors = (a: string, b: string, t: number): string => {
|
|||
: `#${hex(r)}${hex(g)}${hex(bl)}${hex(al)}`;
|
||||
};
|
||||
|
||||
const clamp01 = (t: number) => Math.max(0, Math.min(1, t));
|
||||
|
||||
/**
|
||||
* Sample a gradient at normalized position `t`. Stops are sorted by `at`;
|
||||
* positions outside the stops clamp to the nearest end.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export interface KnobTheme {
|
|||
ticks: string;
|
||||
/** Keyboard focus ring. */
|
||||
focusRing: string;
|
||||
/** Meter/LED zone semantics: healthy level. */
|
||||
zoneGood: string;
|
||||
/** Meter/LED zone semantics: getting hot. */
|
||||
zoneWarn: string;
|
||||
/** Meter/LED zone semantics: too hot / clipping. */
|
||||
zoneHot: string;
|
||||
fontMono: string;
|
||||
fontUI: string;
|
||||
}
|
||||
|
|
@ -39,6 +45,9 @@ export const darkTheme: KnobTheme = {
|
|||
label: 'rgba(255,255,255,0.45)',
|
||||
ticks: 'rgba(255,255,255,0.18)',
|
||||
focusRing: 'rgba(130,180,255,0.65)',
|
||||
zoneGood: '#3df2ad',
|
||||
zoneWarn: '#ffd23e',
|
||||
zoneHot: '#ff4d6b',
|
||||
fontMono: MONO_FONT,
|
||||
fontUI: UI_FONT,
|
||||
};
|
||||
|
|
@ -50,6 +59,9 @@ export const lightTheme: KnobTheme = {
|
|||
label: 'rgba(0,0,0,0.5)',
|
||||
ticks: 'rgba(0,0,0,0.28)',
|
||||
focusRing: 'rgba(0,110,220,0.55)',
|
||||
zoneGood: '#0fa571',
|
||||
zoneWarn: '#c99000',
|
||||
zoneHot: '#d92546',
|
||||
fontMono: MONO_FONT,
|
||||
fontUI: UI_FONT,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import type { Taper } from './math';
|
||||
|
||||
/** What produced a value change. */
|
||||
export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api';
|
||||
/**
|
||||
* What produced a value change.
|
||||
*
|
||||
* Gesture contract — which sources bracket changes with onChangeStart/End:
|
||||
* | source | onChangeStart | onChange (per step) | onChangeEnd |
|
||||
* | 'drag' | yes | yes | yes (pointer up / Escape / disabled) |
|
||||
* | 'wheel' | yes (burst) | yes | yes (250 ms settle) |
|
||||
* | 'keyboard' | yes (burst) | yes | yes (key up / blur) |
|
||||
* | 'reset' | yes | yes | yes |
|
||||
* | 'edit' | yes | yes | yes (type-in editor commit) |
|
||||
* | 'api' | no | yes | no (imperative setValue) |
|
||||
*/
|
||||
export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api';
|
||||
|
||||
export interface ChangeMeta {
|
||||
source: ChangeSource;
|
||||
|
|
@ -80,6 +91,14 @@ export interface KnobCoreProps {
|
|||
* `prefers-reduced-motion` disables it. Default: false.
|
||||
*/
|
||||
animateChanges?: boolean | { duration?: number };
|
||||
/**
|
||||
* When to emit onChange for pointer drags. 'live' (default) fires per
|
||||
* movement; 'release' previews the drag visually but fires onChange exactly
|
||||
* once when the pointer is released — for network-expensive or
|
||||
* side-effectful parameters. Wheel, keyboard and type-in stay immediate
|
||||
* (deliberate acts), and Escape discards the preview without committing.
|
||||
*/
|
||||
commitMode?: 'live' | 'release';
|
||||
/** Drag behaviour. Default: 'rotary'. */
|
||||
interaction?: InteractionMode;
|
||||
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */
|
||||
|
|
|
|||
|
|
@ -48,8 +48,10 @@ export interface UseKnobResult extends KnobState {
|
|||
'data-focus-visible'?: string;
|
||||
style: React.CSSProperties;
|
||||
};
|
||||
/** Imperatively set the value (snapped + clamped). */
|
||||
/** Imperatively set the value (snapped + clamped). Source: 'api' — no Start/End. */
|
||||
setValue: (value: number) => void;
|
||||
/** Commit a value as an edit gesture: fires onChangeStart → onChange → onChangeEnd with source 'edit'. */
|
||||
commitValue: (value: number) => void;
|
||||
}
|
||||
|
||||
interface DragSession {
|
||||
|
|
@ -94,6 +96,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
detentSize = 0.025,
|
||||
wrap = false,
|
||||
animateChanges = false,
|
||||
commitMode = 'live',
|
||||
fineMultiplier = 0.1,
|
||||
angleOffset = 225,
|
||||
angleRange = 270,
|
||||
|
|
@ -191,10 +194,33 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
detents?.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)),
|
||||
[detents, taper, min, max],
|
||||
);
|
||||
// commitMode 'release': drags update this visual preview; onChange fires
|
||||
// once at pointer-up with the final value.
|
||||
const [previewValue, setPreviewValue] = React.useState<number | null>(null);
|
||||
const previewRef = React.useRef<number | null>(null);
|
||||
previewRef.current = previewValue;
|
||||
|
||||
const setFromDrag = React.useCallback(
|
||||
(n: number) =>
|
||||
setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n, 'drag'),
|
||||
[setFromNormalized, detentsN, detentSize],
|
||||
(n: number) => {
|
||||
const nn = detentsN ? applyDetents(n, detentsN, detentSize) : n;
|
||||
if (commitMode === 'release') {
|
||||
setPreviewValue(constrain(taper.fromNormalized(wrapN(nn), min, max)));
|
||||
} else {
|
||||
setFromNormalized(nn, 'drag');
|
||||
}
|
||||
},
|
||||
[setFromNormalized, detentsN, detentSize, commitMode, constrain, taper, wrapN, min, max],
|
||||
);
|
||||
|
||||
/** Commit a value from a type-in editor: full edit-gesture bracketing. */
|
||||
const commitValue = React.useCallback(
|
||||
(v: number) => {
|
||||
const target = constrain(v);
|
||||
onChangeStartRef.current?.(valueRef.current, { source: 'edit' });
|
||||
emit(target, 'edit');
|
||||
onChangeEndRef.current?.(target, { source: 'edit' });
|
||||
},
|
||||
[constrain, emit],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -334,12 +360,23 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
[interactive, applyPointer],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback((e: React.PointerEvent) => {
|
||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
||||
}, []);
|
||||
const endDrag = React.useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
const pending = previewRef.current;
|
||||
if (pending !== null) {
|
||||
// release-mode: commit exactly once, now.
|
||||
setPreviewValue(null);
|
||||
emit(pending, 'drag');
|
||||
onChangeEndRef.current?.(pending, { source: 'drag' });
|
||||
} else {
|
||||
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
||||
}
|
||||
},
|
||||
[emit],
|
||||
);
|
||||
|
||||
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
|
||||
// otherwise it would keep emitting while rendered as disabled.
|
||||
|
|
@ -347,6 +384,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
if (interactive || !session.current) return;
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
setPreviewValue(null);
|
||||
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
||||
}, [interactive]);
|
||||
|
||||
|
|
@ -473,7 +511,12 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
e.preventDefault();
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
emit(s.startValue, 'drag');
|
||||
if (previewRef.current !== null) {
|
||||
// release-mode: nothing was committed — just discard the preview.
|
||||
setPreviewValue(null);
|
||||
} else {
|
||||
emit(s.startValue, 'drag');
|
||||
}
|
||||
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -594,7 +637,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
};
|
||||
}, [value, animMs]);
|
||||
|
||||
const shownValue = animMs ? displayValue : value;
|
||||
const shownValue = previewValue ?? (animMs ? displayValue : value);
|
||||
const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1);
|
||||
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
||||
const originNormalized =
|
||||
|
|
@ -603,7 +646,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
: clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1);
|
||||
|
||||
return {
|
||||
value,
|
||||
value: previewValue ?? value,
|
||||
normalized,
|
||||
originNormalized,
|
||||
angle,
|
||||
|
|
@ -616,6 +659,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
angleRange,
|
||||
ref,
|
||||
setValue: emit,
|
||||
commitValue,
|
||||
bind: {
|
||||
ref,
|
||||
onPointerDown,
|
||||
|
|
@ -631,7 +675,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
|||
tabIndex: disabled ? -1 : 0,
|
||||
'aria-valuemin': min,
|
||||
'aria-valuemax': max,
|
||||
'aria-valuenow': value,
|
||||
'aria-valuenow': previewValue ?? value,
|
||||
'aria-orientation':
|
||||
interaction === 'track-vertical'
|
||||
? ('vertical' as const)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ export { Fader, type FaderProps } from './skins/Fader';
|
|||
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
||||
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
||||
export { Meter, type MeterProps } from './skins/Meter';
|
||||
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
|
||||
export { PushButton, type PushButtonProps } from './skins/PushButton';
|
||||
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
|
||||
export { SegmentSwitch, type SegmentSwitchProps } from './skins/SegmentSwitch';
|
||||
export { ScrubField, type ScrubFieldProps } from './skins/ScrubField';
|
||||
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
|
||||
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
|||
format?: (value: number) => string;
|
||||
textColor?: string;
|
||||
labelColor?: string;
|
||||
/**
|
||||
* Where the value readout sits: 'top' (default), 'end' (after the track —
|
||||
* inline to the right for horizontal faders, below for vertical), or
|
||||
* 'none'.
|
||||
*/
|
||||
valuePosition?: 'top' | 'end' | 'none';
|
||||
/** Click the readout to type an exact value. */
|
||||
editable?: boolean;
|
||||
/** Custom parser for typed input. */
|
||||
|
|
@ -57,6 +63,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
format,
|
||||
textColor,
|
||||
labelColor,
|
||||
valuePosition = 'top',
|
||||
editable = false,
|
||||
parseValue,
|
||||
name,
|
||||
|
|
@ -78,7 +85,11 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
// Align pointer mapping with the handle's travel so grabbing the cap
|
||||
// doesn't jump the value.
|
||||
trackInset: capMain / 2,
|
||||
getAriaValueText:
|
||||
core.getAriaValueText ??
|
||||
(format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined),
|
||||
});
|
||||
const showReadout = showValue && valuePosition !== 'none';
|
||||
const id = React.useId();
|
||||
|
||||
const capCross = breadth * 0.62;
|
||||
|
|
@ -120,76 +131,68 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const atEnd = valuePosition === 'end';
|
||||
const readout = showReadout && (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
width: atEnd ? undefined : '100%',
|
||||
minWidth: atEnd ? '5.5ch' : undefined,
|
||||
display: 'flex',
|
||||
justifyContent: atEnd ? 'flex-start' : 'center',
|
||||
position: 'relative',
|
||||
minHeight: '1.4em',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
// Fixed width so a widening value readout never reflows the layout;
|
||||
// overflowing text spills symmetrically via the centered flex rows.
|
||||
width: w,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{showValue && (
|
||||
<div
|
||||
{editing ? (
|
||||
<ValueInput
|
||||
initial={String(knob.value)}
|
||||
onCommit={raw => {
|
||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||
setEditing(false);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
style={{ position: 'static', transform: 'none', width: atEnd ? '9ch' : '100%', fontSize: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={editable ? () => setEditing(true) : undefined}
|
||||
title={editable ? 'Click to type a value' : undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
minHeight: '1.4em',
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
color: textColor ?? theme.text,
|
||||
minHeight: '1.2em',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: editable ? 'auto' : 'none',
|
||||
cursor: editable ? 'text' : undefined,
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<ValueInput
|
||||
initial={String(knob.value)}
|
||||
onCommit={raw => {
|
||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||
setEditing(false);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={editable ? () => setEditing(true) : undefined}
|
||||
title={editable ? 'Click to type a value' : undefined}
|
||||
style={{
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
color: textColor ?? theme.text,
|
||||
minHeight: '1.2em',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: editable ? 'auto' : 'none',
|
||||
cursor: editable ? 'text' : undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
{...knob.bind}
|
||||
ref={node => {
|
||||
(knob.bind.ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
style={{
|
||||
...knob.bind.style,
|
||||
width: w,
|
||||
height: h,
|
||||
display: 'inline-flex',
|
||||
borderRadius: 8,
|
||||
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
);
|
||||
|
||||
const control = (
|
||||
<div
|
||||
{...knob.bind}
|
||||
ref={node => {
|
||||
(knob.bind.ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
style={{
|
||||
...knob.bind.style,
|
||||
opacity: undefined, // the outer wrapper owns disabled dimming
|
||||
width: w,
|
||||
height: h,
|
||||
display: 'inline-flex',
|
||||
borderRadius: 8,
|
||||
...(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">
|
||||
<defs>
|
||||
{capColor ? (
|
||||
|
|
@ -261,6 +264,36 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
</svg>
|
||||
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-disabled={core.disabled ? '' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
// Fixed width ('top' mode) so a widening readout never reflows the
|
||||
// layout; 'end' mode sizes to content with a min-width readout slot.
|
||||
width: atEnd && !vertical ? undefined : w,
|
||||
opacity: core.disabled ? 0.45 : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{valuePosition === 'top' && readout}
|
||||
{atEnd && !vertical ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{control}
|
||||
{readout}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{control}
|
||||
{atEnd && readout}
|
||||
</>
|
||||
)}
|
||||
{label && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
|
|||
style={style}
|
||||
focusRing={focusRing ?? theme.focusRing}
|
||||
{...core}
|
||||
getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}
|
||||
>
|
||||
<Arc
|
||||
thickness={t}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
|||
...core
|
||||
}, ref) {
|
||||
const theme = useKnobTheme();
|
||||
const knob = useKnob(core);
|
||||
const knob = useKnob({
|
||||
...core,
|
||||
getAriaValueText:
|
||||
core.getAriaValueText ??
|
||||
(format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined),
|
||||
});
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const ring = focusRing ?? theme.focusRing;
|
||||
|
||||
|
|
@ -82,12 +87,14 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
|||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-disabled={core.disabled ? '' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
width: size,
|
||||
opacity: core.disabled ? 0.45 : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
|
@ -108,6 +115,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
|||
}
|
||||
style={{
|
||||
...knob.bind.style,
|
||||
opacity: undefined, // the outer wrapper owns disabled dimming
|
||||
position: 'relative',
|
||||
width: size,
|
||||
height: size,
|
||||
|
|
@ -133,7 +141,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
|||
initial={String(knob.value)}
|
||||
onCommit={raw => {
|
||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||
closeEditor();
|
||||
}}
|
||||
onCancel={closeEditor}
|
||||
|
|
|
|||
184
packages/dreamknob/src/skins/IndicatorLamp.tsx
Normal file
184
packages/dreamknob/src/skins/IndicatorLamp.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
|
||||
export interface IndicatorLampProps {
|
||||
/** Controlled on-state. Pair with `onChange`. */
|
||||
on?: boolean;
|
||||
defaultOn?: boolean;
|
||||
onChange?: (on: boolean) => void;
|
||||
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
|
||||
mode?: 'toggle' | 'momentary';
|
||||
/** Lamp color when lit. */
|
||||
color?: string;
|
||||
/** Lamp diameter in px. Default: 12. */
|
||||
size?: number;
|
||||
shape?: 'round' | 'square';
|
||||
/** Pulse while lit — armed/recording states. Respects prefers-reduced-motion. */
|
||||
blink?: boolean;
|
||||
/** Pure status light: not pressable, not focusable, no switch semantics. */
|
||||
readOnly?: boolean;
|
||||
/** Caption beside the lamp. */
|
||||
label?: string;
|
||||
labelPosition?: 'right' | 'left';
|
||||
disabled?: boolean;
|
||||
/** Render a hidden form input carrying "on"/"off". */
|
||||
name?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pressable LED lamp — the hardware-real toggle for dense headers and
|
||||
* checkbox duty (`● CUE ● MUTE`). Renders `role="switch"`.
|
||||
*/
|
||||
export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampProps>(
|
||||
function IndicatorLamp(
|
||||
{
|
||||
on,
|
||||
defaultOn = false,
|
||||
onChange,
|
||||
mode = 'toggle',
|
||||
color,
|
||||
size = 12,
|
||||
shape = 'round',
|
||||
blink = false,
|
||||
readOnly = false,
|
||||
label,
|
||||
labelPosition = 'right',
|
||||
disabled = false,
|
||||
name,
|
||||
className,
|
||||
style,
|
||||
...aria
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const theme = useKnobTheme();
|
||||
const accent = color ?? theme.accent ?? '#3df2ad';
|
||||
const isControlled = on !== undefined;
|
||||
const [internal, setInternal] = React.useState(defaultOn);
|
||||
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||
const lit = isControlled ? (on as boolean) : internal;
|
||||
const ledRef = React.useRef<HTMLSpanElement>(null);
|
||||
|
||||
const set = (next: boolean) => {
|
||||
if (next === lit) return;
|
||||
if (!isControlled) setInternal(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
// Blink via the Web Animations API — no stylesheet injection needed.
|
||||
React.useEffect(() => {
|
||||
const el = ledRef.current;
|
||||
if (!el || !blink || !lit) return;
|
||||
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
return;
|
||||
const anim = el.animate([{ opacity: 1 }, { opacity: 0.25 }], {
|
||||
duration: 550,
|
||||
iterations: Infinity,
|
||||
direction: 'alternate',
|
||||
easing: 'ease-in-out',
|
||||
});
|
||||
return () => anim.cancel();
|
||||
}, [blink, lit]);
|
||||
|
||||
const handlers = readOnly
|
||||
? {}
|
||||
: 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(!lit) };
|
||||
|
||||
const lamp = (
|
||||
<span
|
||||
ref={ledRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
flex: 'none',
|
||||
borderRadius: shape === 'round' ? '50%' : Math.max(2, size * 0.2),
|
||||
background: lit ? accent : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(0,0,0,0.55)',
|
||||
boxShadow: lit
|
||||
? `0 0 ${size * 0.7}px ${accent}, inset 0 0 ${size * 0.25}px rgba(255,255,255,0.5)`
|
||||
: 'inset 0 1px 2px rgba(0,0,0,0.6)',
|
||||
transition: 'background 80ms, box-shadow 80ms',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role={readOnly ? 'status' : 'switch'}
|
||||
aria-checked={readOnly ? undefined : lit}
|
||||
aria-label={aria['aria-label'] ?? label}
|
||||
disabled={disabled}
|
||||
tabIndex={readOnly ? -1 : undefined}
|
||||
data-on={lit ? '' : undefined}
|
||||
data-focus-visible={focusVisible ? '' : undefined}
|
||||
className={className}
|
||||
{...handlers}
|
||||
onFocus={e => {
|
||||
try {
|
||||
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||
} catch {
|
||||
setFocusVisible(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setFocusVisible(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: labelPosition === 'left' ? 'row-reverse' : 'row',
|
||||
gap: Math.max(5, size * 0.5),
|
||||
padding: 3,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: disabled ? 'not-allowed' : readOnly ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
touchAction: 'manipulation',
|
||||
outline: 'none',
|
||||
boxShadow: focusVisible ? `0 0 0 2px ${theme.focusRing}` : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{lamp}
|
||||
{label && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: Math.max(10, size * 0.9),
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
color: lit ? theme.text : theme.label,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 80ms',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{name && <input type="hidden" name={name} value={lit ? 'on' : 'off'} readOnly />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -38,8 +38,10 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
|||
showValue?: boolean;
|
||||
/** Digit cells in the readout. Default: 4. */
|
||||
digits?: number;
|
||||
/** Decimals in the readout (defaults to the control's decimals, capped at 1). */
|
||||
/** Decimals in the readout. Defaults to the control's decimals — size `digits` to fit. */
|
||||
displayDecimals?: number;
|
||||
/** Unit suffix beside the readout, e.g. "dB". */
|
||||
unit?: string;
|
||||
labelColor?: string;
|
||||
/** Click the readout to type an exact value. */
|
||||
editable?: boolean;
|
||||
|
|
@ -67,6 +69,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
showValue = true,
|
||||
digits = 4,
|
||||
displayDecimals,
|
||||
unit,
|
||||
labelColor,
|
||||
editable = false,
|
||||
parseValue,
|
||||
|
|
@ -77,7 +80,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
}, ref) {
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const theme = useKnobTheme();
|
||||
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
||||
const color = colorProp ?? theme.accent;
|
||||
const ring = focusRing ?? theme.focusRing;
|
||||
const vertical = orientation === 'vertical';
|
||||
const pad = 5;
|
||||
|
|
@ -85,16 +88,22 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
...core,
|
||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||
trackInset: pad,
|
||||
getAriaValueText: core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined),
|
||||
});
|
||||
const id = React.useId();
|
||||
const glowId = `${id}-glow`;
|
||||
|
||||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
||||
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
||||
[zones, color],
|
||||
);
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
|
||||
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||
if (color) return [{ upTo: 1, color }];
|
||||
return [
|
||||
{ upTo: 0.72, color: theme.zoneGood },
|
||||
{ upTo: 0.9, color: theme.zoneWarn },
|
||||
{ upTo: 1, color: theme.zoneHot },
|
||||
];
|
||||
}, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]);
|
||||
const zoneFor = (t: number): string =>
|
||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||
|
||||
|
|
@ -142,12 +151,14 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-disabled={core.disabled ? '' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
width: w,
|
||||
opacity: core.disabled ? 0.45 : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
|
@ -171,22 +182,29 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
|||
initial={String(knob.value)}
|
||||
onCommit={raw => {
|
||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
||||
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||
setEditing(false);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<SegmentDisplay
|
||||
value={knob.value}
|
||||
digits={digits}
|
||||
decimals={displayDecimals ?? Math.min(knob.decimals, 1)}
|
||||
height={12}
|
||||
color={zoneFor(knob.normalized)}
|
||||
background="none"
|
||||
ghostOpacity={0.07}
|
||||
/>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4 }}>
|
||||
<SegmentDisplay
|
||||
value={knob.value}
|
||||
digits={digits}
|
||||
decimals={displayDecimals ?? knob.decimals}
|
||||
height={12}
|
||||
color={zoneFor(knob.normalized)}
|
||||
background="none"
|
||||
ghostOpacity={0.07}
|
||||
/>
|
||||
{unit && (
|
||||
<span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -199,6 +217,7 @@ 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',
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface LEDKnobProps extends SkinProps {
|
|||
segments?: number;
|
||||
/** Digit cells in the center readout. */
|
||||
digits?: number;
|
||||
/** Decimals shown in the readout (defaults to the knob's decimals, capped at 2). */
|
||||
/** Decimals shown in the readout. Defaults to the knob's decimals — make sure `digits` has room. */
|
||||
displayDecimals?: number;
|
||||
labelColor?: string;
|
||||
faceColor?: string;
|
||||
|
|
@ -92,6 +92,7 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
|||
labelColor,
|
||||
faceColor = '#0b0d0e',
|
||||
label,
|
||||
unit,
|
||||
focusRing,
|
||||
className,
|
||||
style,
|
||||
|
|
@ -102,7 +103,15 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
|||
const id = React.useId();
|
||||
const glowId = `${id}-glow`;
|
||||
return (
|
||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||
<Knob
|
||||
ref={ref}
|
||||
size={size}
|
||||
className={className}
|
||||
style={style}
|
||||
focusRing={focusRing}
|
||||
{...core}
|
||||
getAriaValueText={core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined)}
|
||||
>
|
||||
{ctx => (
|
||||
<>
|
||||
<defs>
|
||||
|
|
@ -120,9 +129,22 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
|||
<CenterDisplay
|
||||
color={color}
|
||||
digits={digits}
|
||||
decimals={displayDecimals ?? Math.min(ctx.decimals, 2)}
|
||||
decimals={displayDecimals ?? ctx.decimals}
|
||||
glowId={glowId}
|
||||
/>
|
||||
{unit && (
|
||||
<text
|
||||
x={ctx.center}
|
||||
y={ctx.center + size * 0.2}
|
||||
textAnchor="middle"
|
||||
fontSize={size * 0.08}
|
||||
fill={theme.label}
|
||||
fontFamily={theme.fontMono}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{unit}
|
||||
</text>
|
||||
)}
|
||||
{label && (
|
||||
<KnobLabel
|
||||
color={labelColor ?? theme.label}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
|
|||
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
|
||||
|
||||
return (
|
||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
|
||||
<defs>
|
||||
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
|
||||
<stop offset="0" stopColor={rimStops[0]} />
|
||||
|
|
|
|||
|
|
@ -6,21 +6,24 @@ import type { LEDFaderZone } from './LEDFader';
|
|||
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
||||
|
||||
export interface MeterProps {
|
||||
/** Level to display. The meter is read-only — always controlled. */
|
||||
value: number;
|
||||
/**
|
||||
* Level to display — a single number, or an array for a multi-channel
|
||||
* meter (e.g. `[l, r]` for stereo). The meter is read-only.
|
||||
*/
|
||||
value: number | readonly number[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
taper?: Taper;
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
/** Travel length in px. Default: 160. */
|
||||
length?: number;
|
||||
/** Cross-axis size in px. Default: 16. */
|
||||
/** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */
|
||||
breadth?: number;
|
||||
/** Number of LED segments. Default: 28. */
|
||||
segments?: number;
|
||||
/** LED color when no zones are given. */
|
||||
/** LED color when no zones are given. Defaults to the theme's zone colors. */
|
||||
color?: string;
|
||||
/** Meter zones, e.g. green / yellow / red by normalized position. */
|
||||
/** Meter zones by normalized position. Defaults to theme zoneGood/Warn/Hot. */
|
||||
zones?: readonly LEDFaderZone[];
|
||||
/** Opacity of unlit segments. Default: 0.1. */
|
||||
offOpacity?: number;
|
||||
|
|
@ -31,40 +34,45 @@ export interface MeterProps {
|
|||
peakHold?: number | false;
|
||||
/** Peak indicator color. Defaults to the peak's zone color. */
|
||||
peakColor?: string;
|
||||
/** Dedicated clip LED at the hot end of the meter. Default: true. */
|
||||
/** Dedicated clip LED at the hot end (shared across channels). Default: true. */
|
||||
showClip?: boolean;
|
||||
/** Value at/above which the clip LED lights. Default: `max`. */
|
||||
clipThreshold?: number;
|
||||
/**
|
||||
* Signal used for clip detection, when it differs from the displayed
|
||||
* level — e.g. show RMS on the bar but clip on sample peaks. Defaults
|
||||
* to `value`.
|
||||
* Signal(s) used for clip detection when different from the displayed
|
||||
* level — e.g. show RMS on the bars but clip on sample peaks. Same shape
|
||||
* as `value`. Defaults to `value`.
|
||||
*/
|
||||
clipValue?: number;
|
||||
clipValue?: number | readonly number[];
|
||||
/**
|
||||
* How long the clip LED stays lit in ms, or 'latch' to stay lit until
|
||||
* clicked. Default: 1500.
|
||||
*/
|
||||
clipHold?: number | 'latch';
|
||||
clipColor?: string;
|
||||
/** Fired when the signal first crosses the clip threshold. */
|
||||
/** Fired when any channel first crosses the clip threshold. */
|
||||
onClip?: (value: number) => void;
|
||||
/** Panel color behind the LEDs. */
|
||||
faceColor?: string;
|
||||
label?: string;
|
||||
/** Show a seven-segment readout. Default: false. */
|
||||
/** Show a seven-segment readout (of the hottest channel). Default: false. */
|
||||
showValue?: boolean;
|
||||
digits?: number;
|
||||
displayDecimals?: number;
|
||||
/** Unit suffix beside the readout, e.g. "dB". */
|
||||
unit?: string;
|
||||
labelColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
const asArray = (v: number | readonly number[]): readonly number[] =>
|
||||
Array.isArray(v) ? v : [v as number];
|
||||
|
||||
/**
|
||||
* Read-only LED level meter with peak hold — the display-side sibling of
|
||||
* LEDFader for VU/output metering.
|
||||
* Read-only LED level meter with peak hold and a latching clip LED — pass an
|
||||
* array of values for stereo/multi-channel bars sharing one clip indicator.
|
||||
*/
|
||||
export const Meter: React.FC<MeterProps> = ({
|
||||
value,
|
||||
|
|
@ -75,7 +83,7 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
length = 160,
|
||||
breadth = 16,
|
||||
segments = 28,
|
||||
color = '#3df2ad',
|
||||
color,
|
||||
zones,
|
||||
offOpacity = 0.1,
|
||||
peakHold = 1200,
|
||||
|
|
@ -91,6 +99,7 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
showValue = false,
|
||||
digits = 4,
|
||||
displayDecimals = 1,
|
||||
unit,
|
||||
labelColor,
|
||||
className,
|
||||
style,
|
||||
|
|
@ -98,22 +107,27 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const vertical = orientation === 'vertical';
|
||||
const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1);
|
||||
const values = asArray(value);
|
||||
const channels = values.length;
|
||||
const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1));
|
||||
|
||||
// Peak: track the highest recent level, fall back after `peakHold` ms.
|
||||
const [peak, setPeak] = React.useState(n);
|
||||
// Peaks: per channel, falling back together after `peakHold` ms of no rise.
|
||||
const [peaks, setPeaks] = React.useState<readonly number[]>(ns);
|
||||
React.useEffect(() => {
|
||||
if (peakHold === false) return;
|
||||
if (n >= peak) {
|
||||
setPeak(n);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setPeak(n), peakHold);
|
||||
setPeaks(prev => {
|
||||
const rose = ns.map((n, i) => n >= (prev[i] ?? 0));
|
||||
if (rose.every(Boolean) || prev.length !== ns.length) return ns.map((n, i) => Math.max(n, prev[i] ?? 0));
|
||||
return prev.map((p, i) => Math.max(p, ns[i] ?? 0));
|
||||
});
|
||||
const t = setTimeout(() => setPeaks(ns), peakHold);
|
||||
return () => clearTimeout(t);
|
||||
}, [n, peak, peakHold]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(ns), peakHold]);
|
||||
|
||||
// Clip: latch a dedicated LED when the raw (unclamped) signal reaches the
|
||||
// threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode.
|
||||
// Clip: latch when any channel's raw (unclamped) signal reaches threshold.
|
||||
const clipSignals = clipValue !== undefined ? asArray(clipValue) : values;
|
||||
const hottest = Math.max(...clipSignals);
|
||||
const [clipped, setClipped] = React.useState(false);
|
||||
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const wasOver = React.useRef(false);
|
||||
|
|
@ -121,10 +135,9 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
onClipRef.current = onClip;
|
||||
React.useEffect(() => {
|
||||
if (!showClip) return;
|
||||
const signal = clipValue ?? value;
|
||||
const over = signal >= (clipThreshold ?? max) - 1e-9;
|
||||
const over = hottest >= (clipThreshold ?? max) - 1e-9;
|
||||
if (over) {
|
||||
if (!wasOver.current) onClipRef.current?.(signal);
|
||||
if (!wasOver.current) onClipRef.current?.(hottest);
|
||||
setClipped(true);
|
||||
if (clipHold !== 'latch') {
|
||||
clearTimeout(clipTimer.current);
|
||||
|
|
@ -132,59 +145,84 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
}
|
||||
}
|
||||
wasOver.current = over;
|
||||
}, [value, clipValue, showClip, clipThreshold, max, clipHold]);
|
||||
}, [hottest, showClip, clipThreshold, max, clipHold]);
|
||||
React.useEffect(() => () => clearTimeout(clipTimer.current), []);
|
||||
|
||||
const pad = 4;
|
||||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
||||
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
||||
[zones, color],
|
||||
);
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
|
||||
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||
if (color) return [{ upTo: 1, color }];
|
||||
return [
|
||||
{ upTo: 0.72, color: theme.zoneGood },
|
||||
{ upTo: 0.9, color: theme.zoneWarn },
|
||||
{ upTo: 1, color: theme.zoneHot },
|
||||
];
|
||||
}, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]);
|
||||
const zoneFor = (t: number): string =>
|
||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||
|
||||
const lit = Math.round(n * segments);
|
||||
const peakIdx =
|
||||
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
|
||||
// Reserve room at the hot end for the clip LED (7px LED + 3px gap).
|
||||
// Geometry — clamped so thin meters degrade gracefully instead of
|
||||
// producing negative SVG rects. pad shrinks below breadth 14.
|
||||
const pad = breadth < 14 ? 2 : 4;
|
||||
const cross = Math.max(1, breadth - pad * 2);
|
||||
const chGap = channels > 1 ? 3 : 0;
|
||||
const along = length; // travel axis
|
||||
const across = channels * breadth + (channels - 1) * chGap;
|
||||
const w = vertical ? across : along;
|
||||
const h = vertical ? along : across;
|
||||
|
||||
const clipLed = 7;
|
||||
const clipSpan = showClip ? clipLed + 3 : 0;
|
||||
const slot = (length - pad * 2 - clipSpan) / segments;
|
||||
const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments);
|
||||
const gap = Math.min(2.5, slot * 0.35);
|
||||
const cross = breadth - pad * 2;
|
||||
const segSize = Math.max(0.5, slot - gap);
|
||||
|
||||
const leds: React.ReactNode[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const zone = zoneFor((i + 1) / segments);
|
||||
const isPeak = i === peakIdx && i >= lit;
|
||||
const on = i < lit || isPeak;
|
||||
const fill = isPeak ? (peakColor ?? zone) : zone;
|
||||
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
|
||||
leds.push(
|
||||
vertical ? (
|
||||
<rect
|
||||
key={i}
|
||||
x={pad}
|
||||
y={length - pad - (i + 1) * slot + gap / 2}
|
||||
width={cross}
|
||||
height={slot - gap}
|
||||
{...common}
|
||||
/>
|
||||
) : (
|
||||
<rect key={i} x={pad + i * slot + gap / 2} y={pad} width={slot - gap} height={cross} {...common} />
|
||||
),
|
||||
);
|
||||
const bars: React.ReactNode[] = [];
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const offset = c * (breadth + chGap) + pad;
|
||||
const lit = Math.round((ns[c] ?? 0) * segments);
|
||||
const peakIdx =
|
||||
peakHold !== false && (peaks[c] ?? 0) > 0
|
||||
? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1)
|
||||
: -1;
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const zone = zoneFor((i + 1) / segments);
|
||||
const isPeak = i === peakIdx && i >= lit;
|
||||
const on = i < lit || isPeak;
|
||||
const fill = isPeak ? (peakColor ?? zone) : zone;
|
||||
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
|
||||
bars.push(
|
||||
vertical ? (
|
||||
<rect
|
||||
key={`${c}-${i}`}
|
||||
x={offset}
|
||||
y={along - pad - (i + 1) * slot + gap / 2}
|
||||
width={cross}
|
||||
height={segSize}
|
||||
{...common}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
key={`${c}-${i}`}
|
||||
x={pad + i * slot + gap / 2}
|
||||
y={offset}
|
||||
width={segSize}
|
||||
height={cross}
|
||||
{...common}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hottestShown = Math.max(...values.map(v => clamp(v, min, max)));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="meter"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={clamp(value, min, max)}
|
||||
aria-valuenow={hottestShown}
|
||||
aria-label={aria['aria-label']}
|
||||
data-clipped={clipped ? '' : undefined}
|
||||
style={{
|
||||
|
|
@ -197,28 +235,33 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
}}
|
||||
>
|
||||
{showValue && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 4 }}>
|
||||
<SegmentDisplay
|
||||
value={clamp(value, min, max)}
|
||||
value={hottestShown}
|
||||
digits={digits}
|
||||
decimals={displayDecimals}
|
||||
height={11}
|
||||
color={zoneFor(n)}
|
||||
color={zoneFor(Math.max(...ns))}
|
||||
background="none"
|
||||
ghostOpacity={0.06}
|
||||
/>
|
||||
{unit && (
|
||||
<span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
|
||||
{leds}
|
||||
{bars}
|
||||
{showClip && (
|
||||
<rect
|
||||
data-part="clip"
|
||||
x={vertical ? pad : length - pad - clipLed}
|
||||
x={vertical ? pad : along - pad - clipLed}
|
||||
y={vertical ? pad : pad}
|
||||
width={vertical ? cross : clipLed}
|
||||
height={vertical ? clipLed : cross}
|
||||
width={vertical ? Math.max(1, across - pad * 2) : clipLed}
|
||||
height={vertical ? clipLed : Math.max(1, across - pad * 2)}
|
||||
rx={1.5}
|
||||
fill={clipColor}
|
||||
opacity={clipped ? 1 : 0.14}
|
||||
|
|
|
|||
124
packages/dreamknob/src/skins/MeterBridge.tsx
Normal file
124
packages/dreamknob/src/skins/MeterBridge.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
import { Meter, type MeterProps } from './Meter';
|
||||
|
||||
export interface MeterBridgeChannel {
|
||||
label: string;
|
||||
value: number;
|
||||
/** Peak signal for clip detection, when different from `value`. */
|
||||
clipValue?: number;
|
||||
}
|
||||
|
||||
export interface MeterBridgeProps
|
||||
extends Omit<MeterProps, 'value' | 'clipValue' | 'label' | 'showValue' | 'aria-label'> {
|
||||
channels: readonly MeterBridgeChannel[];
|
||||
/** Numeric peak-hold text above each bar. Default: true. */
|
||||
showPeakText?: boolean;
|
||||
/** Decimals for the peak text. Default: 1. */
|
||||
peakTextDecimals?: number;
|
||||
/** Block caption under the bridge. */
|
||||
label?: string;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A labeled multi-strip meter block — n channels, per-channel peak text, one
|
||||
* shared clip LED. The composition every mixer rebuilds, prebuilt.
|
||||
*/
|
||||
export const MeterBridge: React.FC<MeterBridgeProps> = ({
|
||||
channels,
|
||||
showPeakText = true,
|
||||
peakTextDecimals = 1,
|
||||
label,
|
||||
min = 0,
|
||||
max = 100,
|
||||
breadth = 16,
|
||||
peakHold = 1200,
|
||||
className,
|
||||
style,
|
||||
...meterProps
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const values = channels.map(c => c.value);
|
||||
const clipValues = channels.map(c => c.clipValue ?? c.value);
|
||||
const chGap = channels.length > 1 ? 3 : 0;
|
||||
|
||||
// Numeric peak-hold per channel for the text row.
|
||||
const [peaks, setPeaks] = React.useState<readonly number[]>(values);
|
||||
React.useEffect(() => {
|
||||
if (peakHold === false) return;
|
||||
setPeaks(prev =>
|
||||
prev.length === values.length ? values.map((v, i) => Math.max(v, prev[i] ?? -Infinity)) : values,
|
||||
);
|
||||
const t = setTimeout(() => setPeaks(values), peakHold);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(values), peakHold]);
|
||||
|
||||
const cell: React.CSSProperties = {
|
||||
width: breadth,
|
||||
textAlign: 'center',
|
||||
fontFamily: theme.fontMono,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'visible',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="group"
|
||||
aria-label={meterProps['aria-label' as never] ?? label}
|
||||
style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4, ...style }}
|
||||
>
|
||||
{showPeakText && (
|
||||
<div style={{ display: 'flex', gap: chGap }}>
|
||||
{peaks.map((p, i) => (
|
||||
<span key={i} style={{ ...cell, fontSize: 9, color: theme.label }}>
|
||||
{Math.max(p, min) <= min ? '—' : Math.min(p, max).toFixed(peakTextDecimals)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Meter
|
||||
{...meterProps}
|
||||
min={min}
|
||||
max={max}
|
||||
breadth={breadth}
|
||||
peakHold={peakHold}
|
||||
value={values}
|
||||
clipValue={clipValues}
|
||||
aria-label={undefined}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: chGap }}>
|
||||
{channels.map(c => (
|
||||
<span
|
||||
key={c.label}
|
||||
style={{
|
||||
...cell,
|
||||
fontSize: 9.5,
|
||||
fontFamily: theme.fontUI,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
color: theme.label,
|
||||
}}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{label && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: theme.label,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -36,7 +36,7 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
|
|||
const id = React.useId();
|
||||
const glowId = `${id}-glow`;
|
||||
return (
|
||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
|
||||
<defs>
|
||||
<filter
|
||||
id={glowId}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ export interface PushButtonProps {
|
|||
mode?: 'toggle' | 'momentary';
|
||||
/** LED / active color. */
|
||||
color?: string;
|
||||
/** Show the LED strip. Default: true. */
|
||||
led?: boolean;
|
||||
/**
|
||||
* LED style: 'strip' (default, above the caption), 'dot' (small corner
|
||||
* dot — scales down better for icon-only toggles), or false for none.
|
||||
* `true` is an alias for 'strip'.
|
||||
*/
|
||||
led?: boolean | 'strip' | 'dot';
|
||||
/** Button height in px. Default: 36. */
|
||||
size?: number;
|
||||
/** Explicit width; defaults to content. */
|
||||
|
|
@ -134,7 +138,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
...style,
|
||||
}}
|
||||
>
|
||||
{led && (
|
||||
{(led === true || led === 'strip') && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
|
|
@ -147,6 +151,22 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{led === 'dot' && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: Math.max(3, size * 0.1),
|
||||
right: Math.max(3, size * 0.1),
|
||||
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)',
|
||||
transition: 'background 80ms, box-shadow 80ms',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
|
|||
const bodyR = size / 2 - size * 0.17;
|
||||
|
||||
return (
|
||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
|
||||
<defs>
|
||||
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
|
||||
<stop offset="0" stopColor="#3a3b41" />
|
||||
|
|
|
|||
294
packages/dreamknob/src/skins/ScrubField.tsx
Normal file
294
packages/dreamknob/src/skins/ScrubField.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import * as React from 'react';
|
||||
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
import type { ChangeMeta, ChangeSource } from '../core/types';
|
||||
import { defaultParseValue } from '../components/ValueInput';
|
||||
|
||||
export interface ScrubFieldProps {
|
||||
/** Controlled value. Pair with `onChange`. */
|
||||
value?: number;
|
||||
defaultValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** Increment per scrub pixel-step / wheel notch / arrow key. Default: 1. */
|
||||
step?: number;
|
||||
/** Decimal places of emitted values. Defaults to the precision of `step`. */
|
||||
decimals?: number;
|
||||
onChange?: (value: number, meta: ChangeMeta) => void;
|
||||
onChangeStart?: (value: number, meta: ChangeMeta) => void;
|
||||
onChangeEnd?: (value: number, meta: ChangeMeta) => void;
|
||||
/** The scrub handle text — drag it horizontally to adjust. */
|
||||
label?: string;
|
||||
/** Unit suffix shown after the field, e.g. "px". */
|
||||
unit?: string;
|
||||
/** Horizontal pixels per step while scrubbing. Default: 2. */
|
||||
scrubSensitivity?: number;
|
||||
/** Step multiplier while Shift is held (scrub + wheel). Default: 0.1. */
|
||||
fineMultiplier?: number;
|
||||
/** Custom parser for typed input. Default accepts "1.2k", commas, units. */
|
||||
parseValue?: (text: string) => number | null;
|
||||
/** Input width in ch. Default: 6. */
|
||||
widthCh?: number;
|
||||
accentColor?: string;
|
||||
disabled?: boolean;
|
||||
/** Render a hidden form input carrying the committed value. */
|
||||
name?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact numeric field with After-Effects-style label scrubbing: drag the
|
||||
* label for relative adjustment, click the field to type (draft held until
|
||||
* Enter/blur, Escape reverts). The spinbox replacement for values where
|
||||
* typing dominates but scrubbing should still work.
|
||||
*/
|
||||
export const ScrubField = React.forwardRef<HTMLInputElement, ScrubFieldProps>(
|
||||
function ScrubField(
|
||||
{
|
||||
value,
|
||||
defaultValue,
|
||||
min = -Infinity,
|
||||
max = Infinity,
|
||||
step = 1,
|
||||
decimals,
|
||||
onChange,
|
||||
onChangeStart,
|
||||
onChangeEnd,
|
||||
label,
|
||||
unit,
|
||||
scrubSensitivity = 2,
|
||||
fineMultiplier = 0.1,
|
||||
parseValue,
|
||||
widthCh = 6,
|
||||
accentColor,
|
||||
disabled = false,
|
||||
name,
|
||||
className,
|
||||
style,
|
||||
...aria
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const theme = useKnobTheme();
|
||||
const accent = accentColor ?? theme.accent ?? '#4cc2ff';
|
||||
const dec = decimals ?? (step > 0 ? decimalsFromStep(step) : 3);
|
||||
|
||||
const constrain = React.useCallback(
|
||||
(raw: number) => {
|
||||
let v = clamp(raw, min, max);
|
||||
if (step > 0 && Number.isFinite(min)) v = clamp(snapToStep(v, step, min), min, max);
|
||||
else if (step > 0) v = Math.round(v / step) * step;
|
||||
return roundTo(v, dec);
|
||||
},
|
||||
[min, max, step, dec],
|
||||
);
|
||||
|
||||
const isControlled = value !== undefined;
|
||||
const [internal, setInternal] = React.useState(() => constrain(defaultValue ?? (Number.isFinite(min) ? min : 0)));
|
||||
const committed = constrain(isControlled ? (value as number) : internal);
|
||||
const committedRef = React.useRef(committed);
|
||||
committedRef.current = committed;
|
||||
|
||||
const emit = React.useCallback(
|
||||
(raw: number, source: ChangeSource) => {
|
||||
const next = constrain(raw);
|
||||
if (next === committedRef.current) return;
|
||||
if (!isControlled) {
|
||||
committedRef.current = next;
|
||||
setInternal(next);
|
||||
}
|
||||
onChange?.(next, { source });
|
||||
},
|
||||
[constrain, isControlled, onChange],
|
||||
);
|
||||
|
||||
// Draft typing: the input owns its text between focus and commit.
|
||||
const [draft, setDraft] = React.useState<string | null>(null);
|
||||
// Enter/Escape settle the draft themselves, then blur — the blur handler
|
||||
// must not commit again off its stale closure.
|
||||
const skipBlurCommit = React.useRef(false);
|
||||
const trim = (v: number) => {
|
||||
const s = v.toFixed(dec);
|
||||
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
|
||||
};
|
||||
const shown = draft ?? trim(committed);
|
||||
|
||||
const commitDraft = (text: string) => {
|
||||
const parsed = (parseValue ?? defaultParseValue)(text);
|
||||
if (parsed !== null && Number.isFinite(parsed)) {
|
||||
const target = constrain(parsed);
|
||||
onChangeStart?.(committedRef.current, { source: 'edit' });
|
||||
emit(target, 'edit');
|
||||
onChangeEnd?.(target, { source: 'edit' });
|
||||
}
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
// Label scrubbing.
|
||||
const scrub = React.useRef<{ pointerId: number; lastX: number; acc: number } | null>(null);
|
||||
const [scrubbing, setScrubbing] = React.useState(false);
|
||||
const onScrubDown = (e: React.PointerEvent<HTMLSpanElement>) => {
|
||||
if (disabled) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
scrub.current = { pointerId: e.pointerId, lastX: e.clientX, acc: 0 };
|
||||
setScrubbing(true);
|
||||
onChangeStart?.(committedRef.current, { source: 'drag' });
|
||||
};
|
||||
const onScrubMove = (e: React.PointerEvent<HTMLSpanElement>) => {
|
||||
const s = scrub.current;
|
||||
if (!s || e.pointerId !== s.pointerId) return;
|
||||
s.acc += e.clientX - s.lastX;
|
||||
s.lastX = e.clientX;
|
||||
const stepsMoved = Math.trunc(s.acc / scrubSensitivity);
|
||||
if (stepsMoved !== 0) {
|
||||
s.acc -= stepsMoved * scrubSensitivity;
|
||||
const mult = e.shiftKey ? fineMultiplier : 1;
|
||||
emit(committedRef.current + stepsMoved * step * mult, 'drag');
|
||||
}
|
||||
};
|
||||
const onScrubUp = (e: React.PointerEvent<HTMLSpanElement>) => {
|
||||
if (!scrub.current || e.pointerId !== scrub.current.pointerId) return;
|
||||
scrub.current = null;
|
||||
setScrubbing(false);
|
||||
onChangeEnd?.(committedRef.current, { source: 'drag' });
|
||||
};
|
||||
|
||||
// Wheel over the field (non-passive so the page doesn't scroll).
|
||||
const wrapRef = React.useRef<HTMLDivElement>(null);
|
||||
const wheelState = React.useRef({ active: false, timer: undefined as ReturnType<typeof setTimeout> | undefined });
|
||||
const emitRef = React.useRef(emit);
|
||||
emitRef.current = emit;
|
||||
const cbRef = React.useRef({ onChangeStart, onChangeEnd });
|
||||
cbRef.current = { onChangeStart, onChangeEnd };
|
||||
React.useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el || disabled) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
if (!wheelState.current.active) {
|
||||
wheelState.current.active = true;
|
||||
cbRef.current.onChangeStart?.(committedRef.current, { source: 'wheel' });
|
||||
}
|
||||
const dir = e.deltaY < 0 ? 1 : -1;
|
||||
const mult = e.shiftKey ? fineMultiplier : 1;
|
||||
emitRef.current(committedRef.current + dir * step * mult, 'wheel');
|
||||
clearTimeout(wheelState.current.timer);
|
||||
wheelState.current.timer = setTimeout(() => {
|
||||
wheelState.current.active = false;
|
||||
cbRef.current.onChangeEnd?.(committedRef.current, { source: 'wheel' });
|
||||
}, 250);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
clearTimeout(wheelState.current.timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled, step, fineMultiplier]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={className}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
data-scrubbing={scrubbing ? '' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<span
|
||||
onPointerDown={onScrubDown}
|
||||
onPointerMove={onScrubMove}
|
||||
onPointerUp={onScrubUp}
|
||||
onPointerCancel={onScrubUp}
|
||||
title="Drag to adjust"
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
color: scrubbing ? accent : theme.label,
|
||||
cursor: disabled ? 'not-allowed' : 'ew-resize',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
touchAction: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 80ms',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
role="spinbutton"
|
||||
aria-valuemin={Number.isFinite(min) ? min : undefined}
|
||||
aria-valuemax={Number.isFinite(max) ? max : undefined}
|
||||
aria-valuenow={committed}
|
||||
aria-label={aria['aria-label'] ?? label}
|
||||
disabled={disabled}
|
||||
value={shown}
|
||||
onFocus={e => {
|
||||
setDraft(trim(committed));
|
||||
e.currentTarget.select();
|
||||
}}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={e => {
|
||||
if (skipBlurCommit.current) {
|
||||
skipBlurCommit.current = false;
|
||||
return;
|
||||
}
|
||||
if (draft !== null) commitDraft(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
commitDraft(e.currentTarget.value);
|
||||
skipBlurCommit.current = true;
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setDraft(null);
|
||||
skipBlurCommit.current = true;
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const dir = e.key === 'ArrowUp' ? 1 : -1;
|
||||
const mult = e.shiftKey ? 10 : 1;
|
||||
emit(committedRef.current + dir * step * mult, 'keyboard');
|
||||
setDraft(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: `${widthCh}ch`,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
border: `1px solid ${scrubbing ? accent : 'rgba(255,255,255,0.14)'}`,
|
||||
borderRadius: 6,
|
||||
padding: '4px 7px',
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12.5,
|
||||
color: theme.text,
|
||||
textAlign: 'right',
|
||||
outline: 'none',
|
||||
transition: 'border-color 80ms',
|
||||
}}
|
||||
/>
|
||||
{unit && (
|
||||
<span style={{ fontFamily: theme.fontMono, fontSize: 11, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
{name && <input type="hidden" name={name} value={committed} readOnly />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -53,6 +53,7 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
|
|||
min={min}
|
||||
max={max}
|
||||
steps={detents}
|
||||
getAriaValueText={core.getAriaValueText ?? (positions ? (v: number) => positions[Math.round(((v - min) / (max - min || 1)) * (detents - 1))] ?? String(v) : format ? (v: number) => format(v) : undefined)}
|
||||
>
|
||||
{ctx => {
|
||||
const index = Math.round(ctx.normalized * (detents - 1));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
|
||||
import { applyDetents, clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
import type { ChangeMeta } from '../core/types';
|
||||
|
||||
|
|
@ -8,6 +8,14 @@ export interface XYPadAxis {
|
|||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: number;
|
||||
/**
|
||||
* Flip the axis direction. Y is bottom-up by default (audio convention);
|
||||
* `invert: true` makes it top-down (screen convention). X is left-right;
|
||||
* inverted is right-left.
|
||||
*/
|
||||
invert?: boolean;
|
||||
/** Magnetic snap values for this axis while dragging (like knob detents). */
|
||||
detents?: readonly number[];
|
||||
}
|
||||
|
||||
export interface XYPadValue {
|
||||
|
|
@ -32,6 +40,8 @@ export interface XYPadProps {
|
|||
faceColor?: string;
|
||||
/** Grid divisions per axis (0 disables). Default: 4. */
|
||||
grid?: number;
|
||||
/** Detent capture radius as a fraction of travel. Default: 0.025. */
|
||||
detentSize?: number;
|
||||
/** Show the "x · y" readout above the pad. Default: true. */
|
||||
showValue?: boolean;
|
||||
formatX?: (v: number) => string;
|
||||
|
|
@ -66,6 +76,7 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
|||
color,
|
||||
faceColor = 'rgba(255,255,255,0.04)',
|
||||
grid = 4,
|
||||
detentSize = 0.025,
|
||||
showValue = true,
|
||||
formatX,
|
||||
formatY,
|
||||
|
|
@ -124,16 +135,39 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
|||
onChange?.(c, { source: 'drag' });
|
||||
};
|
||||
|
||||
// Normalized travel <-> value per axis, honoring `invert`.
|
||||
const valueFromN = (n: number, a: typeof ax) => {
|
||||
const span = a.max - a.min;
|
||||
return a.invert ? a.max - n * span : a.min + n * span;
|
||||
};
|
||||
const nFromValue = (v: number, a: typeof ax) => {
|
||||
const span = a.max - a.min;
|
||||
if (span === 0) return 0;
|
||||
return clamp(a.invert ? (a.max - v) / span : (v - a.min) / span, 0, 1);
|
||||
};
|
||||
const detentsNX = React.useMemo(
|
||||
() => ax.detents?.map(d => nFromValue(d, ax)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[ax.detents, ax.min, ax.max, ax.invert],
|
||||
);
|
||||
const detentsNY = React.useMemo(
|
||||
() => ay.detents?.map(d => nFromValue(d, ay)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[ay.detents, ay.min, ay.max, ay.invert],
|
||||
);
|
||||
|
||||
const applyPointer = (clientX: number, clientY: number) => {
|
||||
const s = session.current;
|
||||
if (!s || s.rect.width <= 0 || s.rect.height <= 0) return;
|
||||
const nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
|
||||
const ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
|
||||
emit({ x: ax.min + nx * (ax.max - ax.min), y: ay.min + ny * (ay.max - ay.min) });
|
||||
let nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
|
||||
let ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
|
||||
if (detentsNX) nx = applyDetents(nx, detentsNX, detentSize);
|
||||
if (detentsNY) ny = applyDetents(ny, detentsNY, detentSize);
|
||||
emit({ x: valueFromN(nx, ax), y: valueFromN(ny, ay) });
|
||||
};
|
||||
|
||||
const nx = ax.max === ax.min ? 0 : (pos.x - ax.min) / (ax.max - ax.min);
|
||||
const ny = ay.max === ay.min ? 0 : (pos.y - ay.min) / (ay.max - ay.min);
|
||||
const nx = nFromValue(pos.x, ax);
|
||||
const ny = nFromValue(pos.y, ay);
|
||||
const hx = nx * width;
|
||||
const hy = (1 - ny) * height;
|
||||
|
||||
|
|
@ -153,12 +187,14 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
|||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
width,
|
||||
opacity: disabled ? 0.45 : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
|
@ -238,10 +274,11 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
|||
let handled = true;
|
||||
const p = posRef.current;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft': emit({ ...p, x: p.x - stepX }); break;
|
||||
case 'ArrowRight': emit({ ...p, x: p.x + stepX }); break;
|
||||
case 'ArrowUp': emit({ ...p, y: p.y + stepY }); break;
|
||||
case 'ArrowDown': emit({ ...p, y: p.y - stepY }); break;
|
||||
// Arrows follow the VISUAL direction, so inverted axes negate.
|
||||
case 'ArrowLeft': emit({ ...p, x: p.x - (ax.invert ? -stepX : stepX) }); break;
|
||||
case 'ArrowRight': emit({ ...p, x: p.x + (ax.invert ? -stepX : stepX) }); break;
|
||||
case 'ArrowUp': emit({ ...p, y: p.y + (ay.invert ? -stepY : stepY) }); break;
|
||||
case 'ArrowDown': emit({ ...p, y: p.y - (ay.invert ? -stepY : stepY) }); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) e.preventDefault();
|
||||
|
|
@ -262,7 +299,6 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
|||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair',
|
||||
opacity: disabled ? 0.45 : undefined,
|
||||
outline: 'none',
|
||||
borderRadius: 10,
|
||||
...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue