@vsreact/core 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,12 +7,13 @@ let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB
7
7
  (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
8
8
  batches.push(JSON.parse(json));
9
9
  };
10
- (globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
10
+ const defaultNativeCall = (name: string, argsJson: string) => {
11
11
  const args = JSON.parse(argsJson);
12
12
  nativeCalls.push({ name, args });
13
13
  if (name === "param:get") return JSON.stringify(paramGetResult);
14
14
  return "null";
15
15
  };
16
+ (globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
16
17
 
17
18
  import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
18
19
 
@@ -33,6 +34,7 @@ beforeEach(() => {
33
34
  batches.length = 0;
34
35
  nativeCalls.length = 0;
35
36
  paramGetResult = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
37
+ (globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
36
38
  });
37
39
 
38
40
  describe("vertical Slider", () => {
@@ -191,3 +193,243 @@ describe("Meter", () => {
191
193
  expect(hot).toBeUndefined();
192
194
  });
193
195
  });
196
+
197
+ describe("onLayout + Select", () => {
198
+ /** Map child → parent from appendChild ops, to find a row by its text. */
199
+ const parentMap = () => {
200
+ const map = new Map<number, number>();
201
+ for (const op of allOps() as any[])
202
+ if (op[0] === "appendChild" || op[0] === "insertBefore") map.set(op[2], op[1]);
203
+ return map;
204
+ };
205
+
206
+ test("onLayout registers a layout listener and receives the rect", () => {
207
+ const seen: any[] = [];
208
+ render(<Slider value={0.5} onChange={() => {}} />); // warm-up unrelated tree
209
+ unmount();
210
+ batches.length = 0;
211
+
212
+ const { View: V } = require("./index");
213
+ render(<V onLayout={(r: any) => seen.push(r)} className="w-4" />);
214
+
215
+ const props: any = opsNamed("setProps").find((op: any) =>
216
+ op[2]?.listeners?.includes("layout"),
217
+ );
218
+ expect(props).toBeDefined();
219
+
220
+ dispatch({
221
+ kind: "event",
222
+ nodeId: props[1],
223
+ type: "layout",
224
+ payload: { x: 5, y: 10, width: 160, height: 32 },
225
+ });
226
+ expect(seen).toEqual([{ x: 5, y: 10, width: 160, height: 32 }]);
227
+ });
228
+
229
+ test("Select opens a positioned overlay menu and selects on click", async () => {
230
+ const { Select } = require("./index");
231
+ const seen: number[] = [];
232
+ render(
233
+ <Select options={["SINE", "SAW", "SQR"]} index={0} width={160} onChange={(i: number) => seen.push(i)} />,
234
+ );
235
+ await new Promise((r) => setTimeout(r, 0)); // let effects flush
236
+
237
+ // trigger has both layout + click listeners
238
+ const trigger: any = opsNamed("setProps").find(
239
+ (op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
240
+ );
241
+ expect(trigger).toBeDefined();
242
+
243
+ // layout arrives, then the click opens the menu
244
+ dispatch({
245
+ kind: "event",
246
+ nodeId: trigger[1],
247
+ type: "layout",
248
+ payload: { x: 20, y: 40, width: 160, height: 33 },
249
+ });
250
+ await new Promise((r) => setTimeout(r, 0));
251
+ dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
252
+ await new Promise((r) => setTimeout(r, 0));
253
+
254
+ // menu panel positioned under the trigger, same width
255
+ const panel: any = opsNamed("setProps").find(
256
+ (op: any) => op[2]?.style?.top === 40 + 33 + 4 && op[2]?.style?.left === 20,
257
+ );
258
+ expect(panel).toBeDefined();
259
+ expect(panel[2].style.width).toBe(160);
260
+
261
+ // find the "SAW" row: rawtext -> Text -> row
262
+ const sawText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SAW");
263
+ expect(sawText).toBeDefined();
264
+ const parents = parentMap();
265
+ const rowId = parents.get(parents.get(sawText[1])!)!;
266
+
267
+ dispatch({ kind: "event", nodeId: rowId, type: "click" });
268
+ await new Promise((r) => setTimeout(r, 0));
269
+
270
+ expect(seen).toEqual([1]);
271
+ // menu removed after selection
272
+ expect(allOps().some((op: any) => op[0] === "removeChild")).toBe(true);
273
+ });
274
+
275
+ test("ParamSelect writes a begin/set/end gesture with the index mapping", async () => {
276
+ paramGetResult = { value: 0, text: "Sine", name: "Shape", label: "" };
277
+ const { ParamSelect } = require("./index");
278
+ render(<ParamSelect paramId="shape" options={["SINE", "SAW", "SQR"]} />);
279
+ await new Promise((r) => setTimeout(r, 0));
280
+
281
+ const trigger: any = opsNamed("setProps").find(
282
+ (op: any) => op[2]?.listeners?.includes("layout") && op[2]?.listeners?.includes("click"),
283
+ );
284
+ dispatch({
285
+ kind: "event",
286
+ nodeId: trigger[1],
287
+ type: "layout",
288
+ payload: { x: 0, y: 0, width: 160, height: 33 },
289
+ });
290
+ await new Promise((r) => setTimeout(r, 0));
291
+ dispatch({ kind: "event", nodeId: trigger[1], type: "click" });
292
+ await new Promise((r) => setTimeout(r, 0));
293
+
294
+ const sqrText: any = allOps().find((op: any) => op[0] === "setText" && op[2] === "SQR");
295
+ const parents = parentMap();
296
+ const rowId = parents.get(parents.get(sqrText[1])!)!;
297
+ dispatch({ kind: "event", nodeId: rowId, type: "click" });
298
+
299
+ const set = nativeCalls.find((c) => c.name === "param:set");
300
+ expect(set?.args).toEqual({ id: "shape", value: 1 });
301
+ expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
302
+ expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
303
+ });
304
+ });
305
+
306
+ describe("DAW feel (0.0.6)", () => {
307
+ test("Knob: double-click resets to defaultValue as a full gesture", () => {
308
+ const { Knob } = require("./index");
309
+ const calls: string[] = [];
310
+ render(
311
+ <Knob
312
+ value={0.9}
313
+ defaultValue={0.5}
314
+ onChange={(v: number) => calls.push(`set:${v}`)}
315
+ onBegin={() => calls.push("begin")}
316
+ onEnd={() => calls.push("end")}
317
+ />,
318
+ );
319
+
320
+ const id = nodeWithListener("dblclick");
321
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
322
+ expect(calls).toEqual(["begin", "set:0.5", "end"]);
323
+ });
324
+
325
+ test("Knob: wheel nudges by dy * sensitivity, clamped", () => {
326
+ const { Knob } = require("./index");
327
+ const seen: number[] = [];
328
+ render(<Knob value={0.5} wheelSensitivity={0.4} onChange={(v: number) => seen.push(v)} />);
329
+
330
+ const id = nodeWithListener("wheel");
331
+ dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
332
+ expect(seen.at(-1)).toBeCloseTo(0.54);
333
+
334
+ dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 100 } });
335
+ expect(seen.at(-1)).toBe(1);
336
+ });
337
+
338
+ test("Knob: bipolar arc sweeps from centre in both directions", () => {
339
+ const { Knob } = require("./index");
340
+ render(<Knob bipolar value={0.75} onChange={() => {}} />);
341
+ let arc: any = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueEnd !== undefined);
342
+ expect(arc[2].style.arcValueStart).toBe(0);
343
+ expect(arc[2].style.arcValueEnd).toBeCloseTo(67.5);
344
+
345
+ unmount();
346
+ batches.length = 0;
347
+ render(<Knob bipolar value={0.25} onChange={() => {}} />);
348
+ arc = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueEnd !== undefined);
349
+ expect(arc[2].style.arcValueStart).toBeCloseTo(-67.5);
350
+ expect(arc[2].style.arcValueEnd).toBe(0);
351
+ });
352
+
353
+ test("ParamKnob: host default drives the double-click reset", () => {
354
+ paramGetResult = { value: 0.9, text: "+5 dB", name: "Gain", label: "dB", defaultValue: 0.25 };
355
+ const { ParamKnob } = require("./index");
356
+ render(<ParamKnob paramId="gain" />);
357
+
358
+ const id = nodeWithListener("dblclick");
359
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
360
+
361
+ const set = nativeCalls.find((c) => c.name === "param:set");
362
+ expect(set?.args).toEqual({ id: "gain", value: 0.25 });
363
+ expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
364
+ expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
365
+ });
366
+
367
+ test("Slider: double-click reset + wheel work in both orientations", () => {
368
+ const { Slider } = require("./index");
369
+ const seen: number[] = [];
370
+ render(<Slider vertical value={0.8} defaultValue={0.5} onChange={(v: number) => seen.push(v)} />);
371
+
372
+ dispatch({ kind: "event", nodeId: nodeWithListener("dblclick"), type: "dblclick" });
373
+ expect(seen.at(-1)).toBe(0.5);
374
+
375
+ dispatch({ kind: "event", nodeId: nodeWithListener("wheel"), type: "wheel", payload: { dy: -0.1 } });
376
+ expect(seen.at(-1)).toBeCloseTo(0.76);
377
+ });
378
+ });
379
+
380
+ describe("Tooltip + Modal", () => {
381
+ test("Tooltip shows below its anchor after the hover delay, hides on leave", async () => {
382
+ const { Tooltip } = require("./index");
383
+ render(
384
+ <Tooltip label="Resets to 0 dB" delayMs={15} offset={6}>
385
+ <Slider value={0.5} onChange={() => {}} />
386
+ </Tooltip>,
387
+ );
388
+
389
+ const anchor = nodeWithListener("mouseenter");
390
+ dispatch({
391
+ kind: "event",
392
+ nodeId: anchor,
393
+ type: "layout",
394
+ payload: { x: 10, y: 20, width: 100, height: 30 },
395
+ });
396
+ dispatch({ kind: "event", nodeId: anchor, type: "mouseenter" });
397
+ await new Promise((r) => setTimeout(r, 40));
398
+
399
+ const tip: any = opsNamed("setProps").find(
400
+ (op: any) => op[2]?.style?.top === 20 + 30 + 6 && op[2]?.style?.left === 10,
401
+ );
402
+ expect(tip).toBeDefined();
403
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "Resets to 0 dB")).toBe(true);
404
+
405
+ dispatch({ kind: "event", nodeId: anchor, type: "mouseleave" });
406
+ await new Promise((r) => setTimeout(r, 5));
407
+ expect(allOps().some((op: any) => op[0] === "removeChild")).toBe(true);
408
+ });
409
+
410
+ test("Modal renders a centered panel; backdrop closes, panel doesn't", async () => {
411
+ const { Modal, Text: T } = require("./index");
412
+ const closed: number[] = [];
413
+ render(
414
+ <Modal open onClose={() => closed.push(1)} title="ABOUT" width={280}>
415
+ <T>VSReacT 0.0.6</T>
416
+ </Modal>,
417
+ );
418
+ await new Promise((r) => setTimeout(r, 0));
419
+
420
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ABOUT")).toBe(true);
421
+ const panel: any = opsNamed("setProps").find((op: any) => op[2]?.style?.width === 280);
422
+ expect(panel).toBeDefined();
423
+
424
+ // panel click swallows; backdrop click closes
425
+ dispatch({ kind: "event", nodeId: panel[1], type: "click" });
426
+ expect(closed.length).toBe(0);
427
+
428
+ const backdrop: any = opsNamed("setProps").find(
429
+ (op: any) => op[2]?.listeners?.includes("click") && op[2]?.style?.left === 0 && op[2]?.style?.right === 0,
430
+ );
431
+ expect(backdrop).toBeDefined();
432
+ dispatch({ kind: "event", nodeId: backdrop[1], type: "click" });
433
+ expect(closed).toEqual([1]);
434
+ });
435
+ });
package/src/controls.tsx CHANGED
@@ -2,10 +2,12 @@
2
2
  // switches — drawn by the VSReacT painter and driven by drag gestures.
3
3
  // Each has a Param* variant bound to an APVTS parameter.
4
4
 
5
- import { useRef } from "react";
5
+ import { useEffect, useRef, useState } from "react";
6
6
  import { View, Text } from "./primitives";
7
7
  import { useParameter, useParameterList } from "./parameters";
8
8
  import { useSpring } from "./animation";
9
+ import { useLayoutRect } from "./hooks";
10
+ import { useOverlay } from "./overlay";
9
11
 
10
12
  const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
11
13
 
@@ -23,6 +25,13 @@ export interface KnobProps {
23
25
  label?: string;
24
26
  size?: number;
25
27
  disabled?: boolean;
28
+ /** Double-click resets to this (DAW convention). */
29
+ defaultValue?: number;
30
+ /** Value arc sweeps from 12 o'clock instead of the left stop — for
31
+ centre-based parameters like pan. */
32
+ bipolar?: boolean;
33
+ /** Value change per wheel notch fraction. 0 disables. Default 0.4. */
34
+ wheelSensitivity?: number;
26
35
  trackColor?: string;
27
36
  valueColor?: string;
28
37
  onChange: (value: number) => void;
@@ -36,6 +45,9 @@ export function Knob({
36
45
  label,
37
46
  size = 64,
38
47
  disabled,
48
+ defaultValue,
49
+ bipolar,
50
+ wheelSensitivity = 0.4,
39
51
  trackColor = "#2A2F27",
40
52
  valueColor = "#C6F135",
41
53
  onChange,
@@ -43,6 +55,15 @@ export function Knob({
43
55
  onEnd,
44
56
  }: KnobProps) {
45
57
  const startValue = useRef(0);
58
+ const clamped = clamp01(value);
59
+ const angle = ARC_START + (ARC_END - ARC_START) * clamped;
60
+ const center = (ARC_START + ARC_END) / 2;
61
+
62
+ const nudge = (target: number) => {
63
+ onBegin?.();
64
+ onChange(clamp01(target));
65
+ onEnd?.();
66
+ };
46
67
 
47
68
  return (
48
69
  <View className="items-center gap-2">
@@ -55,19 +76,28 @@ export function Knob({
55
76
  arcColor: valueColor,
56
77
  arcStart: ARC_START,
57
78
  arcEnd: ARC_END,
58
- arcValueEnd: ARC_START + (ARC_END - ARC_START) * Math.min(1, Math.max(0, value)),
79
+ arcValueStart: bipolar ? Math.min(center, angle) : ARC_START,
80
+ arcValueEnd: bipolar ? Math.max(center, angle) : angle,
59
81
  arcThickness: Math.max(3, size * 0.08),
60
82
  }}
61
83
  onDragStart={
62
84
  disabled
63
85
  ? undefined
64
86
  : () => {
65
- startValue.current = value;
87
+ startValue.current = clamped;
66
88
  onBegin?.();
67
89
  }
68
90
  }
69
91
  onDrag={disabled ? undefined : (e) => onChange(dragToValue(startValue.current, e.dy))}
70
92
  onDragEnd={disabled ? undefined : () => onEnd?.()}
93
+ onDoubleClick={
94
+ disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue)
95
+ }
96
+ onWheel={
97
+ disabled || wheelSensitivity === 0
98
+ ? undefined
99
+ : (e) => nudge(clamped + e.dy * wheelSensitivity)
100
+ }
71
101
  >
72
102
  {text !== undefined ? (
73
103
  <Text
@@ -89,12 +119,15 @@ export interface ParamKnobProps {
89
119
  paramId: string;
90
120
  label?: string;
91
121
  size?: number;
122
+ bipolar?: boolean;
123
+ wheelSensitivity?: number;
92
124
  trackColor?: string;
93
125
  valueColor?: string;
94
126
  }
95
127
 
96
- /** A Knob bound to an APVTS parameter (via ParameterBridge). */
97
- export function ParamKnob({ paramId, label, size, trackColor, valueColor }: ParamKnobProps) {
128
+ /** A Knob bound to an APVTS parameter double-click resets to the
129
+ host's default, the wheel nudges, gestures stay automation-safe. */
130
+ export function ParamKnob({ paramId, label, ...rest }: ParamKnobProps) {
98
131
  const param = useParameter(paramId);
99
132
 
100
133
  return (
@@ -102,12 +135,11 @@ export function ParamKnob({ paramId, label, size, trackColor, valueColor }: Para
102
135
  value={param.value}
103
136
  text={param.text}
104
137
  label={label ?? param.name.toUpperCase()}
105
- size={size}
106
- trackColor={trackColor}
107
- valueColor={valueColor}
138
+ defaultValue={param.defaultValue}
108
139
  onChange={param.set}
109
140
  onBegin={param.begin}
110
141
  onEnd={param.end}
142
+ {...rest}
111
143
  />
112
144
  );
113
145
  }
@@ -122,6 +154,10 @@ export interface SliderProps {
122
154
  vertical?: boolean;
123
155
  label?: string;
124
156
  disabled?: boolean;
157
+ /** Double-click resets to this (DAW convention). */
158
+ defaultValue?: number;
159
+ /** Value change per wheel notch fraction. 0 disables. Default 0.4. */
160
+ wheelSensitivity?: number;
125
161
  trackColor?: string;
126
162
  valueColor?: string;
127
163
  onChange: (value: number) => void;
@@ -136,6 +172,8 @@ export function Slider({
136
172
  vertical,
137
173
  label,
138
174
  disabled,
175
+ defaultValue,
176
+ wheelSensitivity = 0.4,
139
177
  trackColor = "#2A2F27",
140
178
  valueColor = "#C6F135",
141
179
  onChange,
@@ -153,6 +191,18 @@ export function Slider({
153
191
  };
154
192
  const onDragEnd = disabled ? undefined : () => onEnd?.();
155
193
 
194
+ const nudge = (target: number) => {
195
+ onBegin?.();
196
+ onChange(clamp01(target));
197
+ onEnd?.();
198
+ };
199
+ const onDoubleClick =
200
+ disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue);
201
+ const onWheel =
202
+ disabled || wheelSensitivity === 0
203
+ ? undefined
204
+ : (e: { dy: number }) => nudge(clamped + e.dy * wheelSensitivity);
205
+
156
206
  if (vertical) {
157
207
  return (
158
208
  <View className="items-center gap-2">
@@ -162,6 +212,8 @@ export function Slider({
162
212
  onDragStart={onDragStart}
163
213
  onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height))}
164
214
  onDragEnd={onDragEnd}
215
+ onDoubleClick={onDoubleClick}
216
+ onWheel={onWheel}
165
217
  >
166
218
  <View
167
219
  className="absolute w-[4] rounded-full left-[7] top-0 bottom-0"
@@ -191,6 +243,8 @@ export function Slider({
191
243
  onDragStart={onDragStart}
192
244
  onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width))}
193
245
  onDragEnd={onDragEnd}
246
+ onDoubleClick={onDoubleClick}
247
+ onWheel={onWheel}
194
248
  >
195
249
  <View className="h-[4] rounded-full" style={{ backgroundColor: trackColor }} />
196
250
  <View
@@ -215,22 +269,23 @@ export interface ParamSliderProps {
215
269
  width?: number;
216
270
  height?: number;
217
271
  vertical?: boolean;
272
+ wheelSensitivity?: number;
218
273
  }
219
274
 
220
- /** A Slider bound to an APVTS parameter (via ParameterBridge). */
221
- export function ParamSlider({ paramId, label, width, height, vertical }: ParamSliderProps) {
275
+ /** A Slider bound to an APVTS parameter double-click resets to the
276
+ host's default, the wheel nudges, gestures stay automation-safe. */
277
+ export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
222
278
  const param = useParameter(paramId);
223
279
 
224
280
  return (
225
281
  <Slider
226
282
  value={param.value}
227
283
  label={label ?? param.name.toUpperCase()}
228
- width={width}
229
- height={height}
230
- vertical={vertical}
284
+ defaultValue={param.defaultValue}
231
285
  onChange={param.set}
232
286
  onBegin={param.begin}
233
287
  onEnd={param.end}
288
+ {...rest}
234
289
  />
235
290
  );
236
291
  }
@@ -564,3 +619,151 @@ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }
564
619
  </View>
565
620
  );
566
621
  }
622
+
623
+ // ── Select ─────────────────────────────────────────────────────────────
624
+
625
+ export interface SelectProps {
626
+ options: string[];
627
+ index: number;
628
+ /** Trigger width; the menu matches it. Default 160. */
629
+ width?: number;
630
+ label?: string;
631
+ disabled?: boolean;
632
+ trackColor?: string;
633
+ menuColor?: string;
634
+ activeColor?: string;
635
+ textColor?: string;
636
+ activeTextColor?: string;
637
+ /** Menu scrolls beyond this height. Default 190. */
638
+ maxMenuHeight?: number;
639
+ onChange: (index: number) => void;
640
+ }
641
+
642
+ const SELECT_ROW_HEIGHT = 30;
643
+
644
+ /** A dropdown: the menu renders in the overlay layer, positioned under
645
+ the trigger via onLayout, with a click-away backdrop and a scrolling
646
+ option list. */
647
+ export function Select({
648
+ options,
649
+ index,
650
+ width = 160,
651
+ label,
652
+ disabled,
653
+ trackColor = "#2A2F27",
654
+ menuColor = "#20241F",
655
+ activeColor = "#C6F135",
656
+ textColor = "#d4d4d8",
657
+ activeTextColor = "#09090b",
658
+ maxMenuHeight = 190,
659
+ onChange,
660
+ }: SelectProps) {
661
+ const [open, setOpen] = useState(false);
662
+ const [rect, onLayout] = useLayoutRect();
663
+ const overlay = useOverlay();
664
+ const current = Math.min(options.length - 1, Math.max(0, index));
665
+
666
+ useEffect(() => {
667
+ if (!open || rect === null) {
668
+ overlay.hide();
669
+ return;
670
+ }
671
+
672
+ const menuHeight = Math.min(options.length * SELECT_ROW_HEIGHT + 8, maxMenuHeight);
673
+
674
+ overlay.show(
675
+ <View className="absolute inset-0" onClick={() => setOpen(false)}>
676
+ <View
677
+ className="absolute rounded-lg border shadow-lg overflow-hidden"
678
+ style={{
679
+ left: rect.x,
680
+ top: rect.y + rect.height + 4,
681
+ width: rect.width,
682
+ backgroundColor: menuColor,
683
+ borderColor: "#00000066",
684
+ }}
685
+ >
686
+ <View className="overflow-y-scroll p-[4] gap-[2]" style={{ height: menuHeight }}>
687
+ {options.map((option, i) => (
688
+ <View
689
+ key={`${option}-${i}`}
690
+ className="px-3 py-[6] rounded cursor-pointer hover:bg-white/10"
691
+ style={{ backgroundColor: i === current ? activeColor : "#00000000" }}
692
+ onClick={() => {
693
+ onChange(i);
694
+ setOpen(false);
695
+ }}
696
+ >
697
+ <Text
698
+ className="text-[12] font-medium"
699
+ style={{ color: i === current ? activeTextColor : textColor }}
700
+ >
701
+ {option}
702
+ </Text>
703
+ </View>
704
+ ))}
705
+ </View>
706
+ </View>
707
+ </View>,
708
+ );
709
+ // eslint-disable-next-line react-hooks/exhaustive-deps
710
+ }, [open, rect, options, current, menuColor, activeColor, textColor, activeTextColor, maxMenuHeight]);
711
+
712
+ return (
713
+ <View className="items-center gap-2">
714
+ <View
715
+ className={`flex-row items-center justify-between rounded-lg border px-3 py-[8] ${
716
+ disabled ? "opacity-40" : "cursor-pointer"
717
+ }`}
718
+ style={{ width, backgroundColor: trackColor, borderColor: "#00000055" }}
719
+ onLayout={onLayout}
720
+ onClick={disabled ? undefined : () => setOpen((v) => !v)}
721
+ >
722
+ <Text className="text-[12] font-medium" style={{ color: textColor }}>
723
+ {options[current] ?? ""}
724
+ </Text>
725
+ <Text className="text-[9]" style={{ color: textColor, opacity: 0.7 }}>
726
+ {open ? "▲" : "▼"}
727
+ </Text>
728
+ </View>
729
+ {label !== undefined ? (
730
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
731
+ ) : null}
732
+ </View>
733
+ );
734
+ }
735
+
736
+ export interface ParamSelectProps {
737
+ paramId: string;
738
+ options: string[];
739
+ width?: number;
740
+ label?: string;
741
+ trackColor?: string;
742
+ menuColor?: string;
743
+ activeColor?: string;
744
+ textColor?: string;
745
+ activeTextColor?: string;
746
+ maxMenuHeight?: number;
747
+ }
748
+
749
+ /** A Select bound to a choice-style APVTS parameter (same value↔index
750
+ mapping as ParamSegmented). */
751
+ export function ParamSelect({ paramId, options, label, ...rest }: ParamSelectProps) {
752
+ const param = useParameter(paramId);
753
+ const count = Math.max(1, options.length);
754
+ const index = Math.round(param.value * (count - 1));
755
+
756
+ return (
757
+ <Select
758
+ options={options}
759
+ index={index}
760
+ label={label ?? param.name.toUpperCase()}
761
+ onChange={(i) => {
762
+ param.begin();
763
+ param.set(count <= 1 ? 0 : i / (count - 1));
764
+ param.end();
765
+ }}
766
+ {...rest}
767
+ />
768
+ );
769
+ }
package/src/hooks.ts CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { native } from "./native";
5
+ import type { LayoutRect } from "./primitives";
6
+
7
+ /**
8
+ * Captures a node's root-space rect from its onLayout prop:
9
+ *
10
+ * const [rect, onLayout] = useLayoutRect();
11
+ * <View onLayout={onLayout} /> // rect updates whenever layout moves it
12
+ */
13
+ export function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect) => void] {
14
+ const [rect, setRect] = useState<LayoutRect | null>(null);
15
+ return [rect, setRect];
16
+ }
5
17
 
6
18
  /**
7
19
  * Subscribes to a C++ event (RootView::sendNativeEvent) for the lifetime
package/src/hostConfig.ts CHANGED
@@ -24,6 +24,8 @@ const hostTypes: Record<string, string> = {
24
24
 
25
25
  const eventPropNames: Record<string, string> = {
26
26
  onClick: "click",
27
+ onDoubleClick: "dblclick",
28
+ onWheel: "wheel",
27
29
  onMouseEnter: "mouseenter",
28
30
  onMouseLeave: "mouseleave",
29
31
  onMouseDown: "mousedown",
@@ -31,6 +33,7 @@ const eventPropNames: Record<string, string> = {
31
33
  onDragStart: "dragstart",
32
34
  onDrag: "drag",
33
35
  onDragEnd: "dragend",
36
+ onLayout: "layout",
34
37
  onChange: "change",
35
38
  onSubmit: "submit",
36
39
  onFocus: "focus",
package/src/index.ts CHANGED
@@ -13,7 +13,10 @@ export type {
13
13
  } from "./primitives";
14
14
  export { render, unmount } from "./render";
15
15
  export { native } from "./native";
16
- export { useNativeEvent, useDebounced } from "./hooks";
16
+ export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
17
+ export { useOverlay, OverlayLayer } from "./overlay";
18
+ export { Tooltip, Modal } from "./popover";
19
+ export type { TooltipProps, ModalProps } from "./popover";
17
20
  export { configureTheme, tw } from "./tw";
18
21
  export type { Style, ResolvedClasses } from "./tw";
19
22
  export { cx } from "./cx";
@@ -28,12 +31,14 @@ export {
28
31
  Toggle,
29
32
  XYPad,
30
33
  Segmented,
34
+ Select,
31
35
  GenericEditor,
32
36
  ParamKnob,
33
37
  ParamSlider,
34
38
  ParamToggle,
35
39
  ParamXYPad,
36
40
  ParamSegmented,
41
+ ParamSelect,
37
42
  dragToValue,
38
43
  } from "./controls";
39
44
  export type {
@@ -42,13 +47,15 @@ export type {
42
47
  ToggleProps,
43
48
  XYPadProps,
44
49
  SegmentedProps,
50
+ SelectProps,
45
51
  GenericEditorProps,
46
52
  ParamKnobProps,
47
53
  ParamSliderProps,
48
54
  ParamToggleProps,
49
55
  ParamXYPadProps,
50
56
  ParamSegmentedProps,
57
+ ParamSelectProps,
51
58
  } from "./controls";
52
59
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
53
60
  export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
54
- export type { DragEventPayload } from "./primitives";
61
+ export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";