@tremolo-ui/react 0.5.0 → 0.7.0

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.
Files changed (86) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +1669 -541
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1571 -486
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1571 -486
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1640 -538
  16. package/dist/index.js.map +1 -1
  17. package/package.json +29 -27
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/DropZone/index.tsx +100 -0
  20. package/src/components/FileInput/Trigger.tsx +39 -0
  21. package/src/components/FileInput/context.tsx +24 -0
  22. package/src/components/FileInput/index.tsx +144 -0
  23. package/src/components/Knob/ActiveLine.tsx +7 -6
  24. package/src/components/Knob/InactiveLine.tsx +9 -10
  25. package/src/components/Knob/SVGRoot.tsx +5 -3
  26. package/src/components/Knob/Thumb.tsx +33 -11
  27. package/src/components/Knob/context.tsx +17 -1
  28. package/src/components/Knob/index.tsx +156 -60
  29. package/src/components/NumberInput/DecrementStepper.tsx +3 -10
  30. package/src/components/NumberInput/IncrementStepper.tsx +3 -10
  31. package/src/components/NumberInput/InputField.tsx +185 -108
  32. package/src/components/NumberInput/Stepper.tsx +120 -84
  33. package/src/components/NumberInput/StepperButton.tsx +100 -0
  34. package/src/components/NumberInput/context.tsx +34 -8
  35. package/src/components/NumberInput/index.tsx +270 -82
  36. package/src/components/Piano/index.tsx +363 -200
  37. package/src/components/PointsEditor/Background.tsx +13 -10
  38. package/src/components/PointsEditor/Container.tsx +83 -29
  39. package/src/components/PointsEditor/Point.tsx +241 -79
  40. package/src/components/PointsEditor/SelectionBox.tsx +48 -0
  41. package/src/components/PointsEditor/context.tsx +78 -10
  42. package/src/components/PointsEditor/index.tsx +440 -41
  43. package/src/components/Slider/Marks.tsx +48 -19
  44. package/src/components/Slider/MarksOption.tsx +64 -45
  45. package/src/components/Slider/Thumb.tsx +107 -55
  46. package/src/components/Slider/Track.tsx +66 -62
  47. package/src/components/Slider/context.tsx +4 -2
  48. package/src/components/Slider/index.tsx +136 -77
  49. package/src/components/Slider/type.ts +29 -9
  50. package/src/components/XYPad/Area.tsx +46 -41
  51. package/src/components/XYPad/Thumb.tsx +131 -64
  52. package/src/components/XYPad/context.tsx +4 -1
  53. package/src/components/XYPad/index.tsx +183 -53
  54. package/src/components/_util/Placement.tsx +68 -0
  55. package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
  56. package/src/components/_util/css-length.ts +13 -0
  57. package/src/components/_util/decimal-digits.ts +23 -0
  58. package/src/components/_util/visually-hidden.ts +25 -0
  59. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  60. package/src/css-variables.ts +23 -0
  61. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  62. package/src/hooks/_internal/useCheckSteps.ts +169 -0
  63. package/src/hooks/useAnimationFrame.ts +17 -7
  64. package/src/hooks/useDrag.ts +51 -12
  65. package/src/hooks/useDragValue.ts +71 -11
  66. package/src/hooks/useDropZone.ts +78 -0
  67. package/src/hooks/useEventListener.ts +10 -6
  68. package/src/hooks/useLongPress.ts +42 -8
  69. package/src/hooks/useMIDIAccess.ts +82 -11
  70. package/src/hooks/useMIDIInput.ts +45 -13
  71. package/src/hooks/useMIDIMessage.ts +23 -5
  72. package/src/hooks/useWheel.ts +4 -1
  73. package/src/index.ts +45 -25
  74. package/src/input-event.ts +32 -0
  75. package/dist/index.css +0 -501
  76. package/src/Globals.d.ts +0 -3
  77. package/src/components/Knob/index.css +0 -81
  78. package/src/components/NumberInput/index.css +0 -121
  79. package/src/components/NumberInput/stepperButton.tsx +0 -90
  80. package/src/components/Piano/index.css +0 -90
  81. package/src/components/PointsEditor/index.css +0 -35
  82. package/src/components/Slider/index.css +0 -125
  83. package/src/components/XYPad/index.css +0 -60
  84. package/src/components/_util/index.ts +0 -28
  85. package/src/styles/global.css +0 -19
  86. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
package/dist/index.cjs CHANGED
@@ -16,21 +16,24 @@ var __copyProps = (to, from, except, desc) => {
16
16
  }
17
17
  return to;
18
18
  };
19
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
20
  value: mod,
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- let clsx = require("clsx");
25
- clsx = __toESM(clsx);
24
+ const require_compose_refs = require("./compose-refs.cjs");
26
25
  let react = require("react");
27
- react = __toESM(react);
26
+ react = __toESM(react, 1);
28
27
  let _tremolo_ui_dom = require("@tremolo-ui/dom");
29
28
  let react_jsx_runtime = require("react/jsx-runtime");
30
29
  let _tremolo_ui_functions = require("@tremolo-ui/functions");
31
30
  //#region src/components/AnimationCanvas/index.tsx
32
- function AnimationCanvas({ draw, init, animate = true, options, width = 100, height = 100, relativeSize = false, reduceFlickering = true, className, onContextMenu = (event) => event.preventDefault(), ...props }) {
31
+ /**
32
+ * A simple animatable canvas with requestAnimationFrame()
33
+ */
34
+ const AnimationCanvas = /* @__PURE__ */ (0, react.forwardRef)(function AnimationCanvas({ draw, init, animate = true, options, reduceFlickering = true, width = 100, height = 100, resizable = false, className, onContextMenu = (event) => event.preventDefault(), ...props }, forwardedRef) {
33
35
  const [node, setNode] = (0, react.useState)(null);
36
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, setNode);
34
37
  const latest = (0, react.useRef)({
35
38
  draw,
36
39
  init,
@@ -41,30 +44,6 @@ function AnimationCanvas({ draw, init, animate = true, options, width = 100, hei
41
44
  options
42
45
  });
43
46
  const instanceRef = (0, react.useRef)(null);
44
- /** Set while the instance below is newer than the effect that pushes into it. */
45
- const justCreated = (0, react.useRef)(false);
46
- (0, react.useEffect)(() => {
47
- if (!node) return;
48
- const current = latest.current;
49
- const instance = (0, _tremolo_ui_dom.createAnimationCanvas)(node, {
50
- draw: (context, frame) => latest.current.draw(context, frame),
51
- init: (context, size) => latest.current.init?.(context, size),
52
- animate: current.animate,
53
- size: {
54
- width: current.width,
55
- height: current.height
56
- },
57
- reduceFlickering: current.reduceFlickering,
58
- relativeSize,
59
- contextAttributes: current.options
60
- });
61
- instanceRef.current = instance;
62
- justCreated.current = true;
63
- return () => {
64
- instanceRef.current = null;
65
- instance.destroy();
66
- };
67
- }, [node, relativeSize]);
68
47
  (0, react.useEffect)(() => {
69
48
  latest.current = {
70
49
  draw,
@@ -75,10 +54,6 @@ function AnimationCanvas({ draw, init, animate = true, options, width = 100, hei
75
54
  reduceFlickering,
76
55
  options
77
56
  };
78
- if (justCreated.current) {
79
- justCreated.current = false;
80
- return;
81
- }
82
57
  instanceRef.current?.update({
83
58
  animate,
84
59
  size: {
@@ -88,13 +63,34 @@ function AnimationCanvas({ draw, init, animate = true, options, width = 100, hei
88
63
  reduceFlickering
89
64
  });
90
65
  });
66
+ (0, react.useEffect)(() => {
67
+ if (!node) return;
68
+ const current = latest.current;
69
+ const instance = (0, _tremolo_ui_dom.createAnimationCanvas)(node, {
70
+ draw: (context, frame) => latest.current.draw(context, frame),
71
+ init: (context, size) => latest.current.init?.(context, size),
72
+ animate: current.animate,
73
+ size: {
74
+ width: current.width,
75
+ height: current.height
76
+ },
77
+ reduceFlickering: current.reduceFlickering,
78
+ resizable,
79
+ contextAttributes: current.options
80
+ });
81
+ instanceRef.current = instance;
82
+ return () => {
83
+ instanceRef.current = null;
84
+ instance.destroy();
85
+ };
86
+ }, [node, resizable]);
91
87
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
92
- className: (0, clsx.default)("tremolo-animation-canvas", className),
93
- ref: setNode,
88
+ className,
89
+ ref: composedRef,
94
90
  onContextMenu,
95
91
  ...props
96
92
  });
97
- }
93
+ });
98
94
  //#endregion
99
95
  //#region src/hooks/_internal/useCallbackRef.ts
100
96
  /**
@@ -111,6 +107,305 @@ function useCallbackRef(callback, deps = []) {
111
107
  return (0, react.useCallback)((...args) => callbackRef.current?.(...args), deps);
112
108
  }
113
109
  //#endregion
110
+ //#region src/hooks/useDropZone.ts
111
+ /**
112
+ * Take files dropped onto an element.
113
+ *
114
+ * Any element can become a drop target this way — a waveform display, a track
115
+ * lane, a canvas — without wrapping it in anything.
116
+ *
117
+ * @returns a ref callback for the element, and whether a drag is over it
118
+ */
119
+ function useDropZone({ accept, multiple, disabled, onDrop, onReject } = {}) {
120
+ const dropHandler = useCallbackRef(onDrop);
121
+ const rejectHandler = useCallbackRef(onReject);
122
+ const [state, setState] = (0, react.useState)({
123
+ over: false,
124
+ invalid: false
125
+ });
126
+ const [node, setNode] = (0, react.useState)(null);
127
+ const instanceRef = (0, react.useRef)(null);
128
+ const latest = (0, react.useRef)({
129
+ accept,
130
+ multiple,
131
+ disabled
132
+ });
133
+ (0, react.useEffect)(() => {
134
+ if (!node) return;
135
+ const instance = (0, _tremolo_ui_dom.createDropZone)(node, {
136
+ ...latest.current,
137
+ onDrop: (files, event) => dropHandler(files, event),
138
+ onReject: (files, event) => rejectHandler(files, event),
139
+ onStateChange: (next) => setState(next)
140
+ });
141
+ instanceRef.current = instance;
142
+ return () => {
143
+ instanceRef.current = null;
144
+ instance.destroy();
145
+ setState({
146
+ over: false,
147
+ invalid: false
148
+ });
149
+ };
150
+ }, [
151
+ node,
152
+ dropHandler,
153
+ rejectHandler
154
+ ]);
155
+ (0, react.useEffect)(() => {
156
+ latest.current = {
157
+ accept,
158
+ multiple,
159
+ disabled
160
+ };
161
+ instanceRef.current?.update({
162
+ accept,
163
+ multiple,
164
+ disabled
165
+ });
166
+ });
167
+ return {
168
+ refCallback: setNode,
169
+ over: state.over,
170
+ invalid: state.invalid
171
+ };
172
+ }
173
+ /**
174
+ * An area that takes files dropped onto it.
175
+ */
176
+ const DropZone = { Root: /* @__PURE__ */ (0, react.forwardRef)(({ accept, multiple = false, disabled = false, onDrop, onReject, children, ...props }, ref) => {
177
+ const { refCallback, over, invalid } = useDropZone({
178
+ accept,
179
+ multiple,
180
+ disabled,
181
+ onDrop,
182
+ onReject
183
+ });
184
+ const composedRef = require_compose_refs.useComposedRefs(ref, refCallback);
185
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
186
+ ref: composedRef,
187
+ "data-dragover": over ? "" : void 0,
188
+ "data-invalid": invalid ? "" : void 0,
189
+ "data-disabled": disabled ? "" : void 0,
190
+ ...props,
191
+ children
192
+ });
193
+ }) };
194
+ //#endregion
195
+ //#region src/components/_util/visually-hidden.ts
196
+ /**
197
+ * Take an element out of sight while leaving it in the accessibility tree and
198
+ * in the tab order.
199
+ *
200
+ * `display: none` and `visibility: hidden` would remove it from both, and the
201
+ * native control underneath a headless component is what carries the ARIA and
202
+ * the keyboard behaviour. `pointer-events: none` is safe because nothing is
203
+ * ever clicked here directly: a `<label>` forwards its click, and a drag is
204
+ * handled by the part that is visible.
205
+ */
206
+ const visuallyHiddenStyle = {
207
+ position: "absolute",
208
+ width: 1,
209
+ height: 1,
210
+ padding: 0,
211
+ margin: -1,
212
+ overflow: "hidden",
213
+ clip: "rect(0, 0, 0, 0)",
214
+ clipPath: "inset(50%)",
215
+ whiteSpace: "nowrap",
216
+ border: 0,
217
+ pointerEvents: "none"
218
+ };
219
+ //#endregion
220
+ //#region src/components/FileInput/context.tsx
221
+ const FileInputContext = /* @__PURE__ */ (0, react.createContext)(null);
222
+ const FileInputProvider = FileInputContext.Provider;
223
+ function useFileInputContext(selector) {
224
+ const context = (0, react.useContext)(FileInputContext);
225
+ if (!context) throw new Error("Missing FileInputContext.Provider in the tree");
226
+ return selector ? selector(context) : context;
227
+ }
228
+ //#endregion
229
+ //#region src/components/FileInput/Trigger.tsx
230
+ /**
231
+ * Opens the file picker, and names the input for a screen reader.
232
+ *
233
+ * A `<label>` rather than a `<button>`: the browser forwards the click to the
234
+ * input on its own, the input stays the one thing in the tab order, and its
235
+ * accessible name comes from this text without an `aria-label` to keep in
236
+ * step. A disabled input ignores the click, so nothing has to be intercepted.
237
+ */
238
+ const Trigger = /* @__PURE__ */ (0, react.forwardRef)(({ children, ...props }, ref) => {
239
+ const { inputId, disabled } = useFileInputContext();
240
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
241
+ ref,
242
+ htmlFor: inputId,
243
+ "data-disabled": disabled ? "" : void 0,
244
+ ...props,
245
+ children
246
+ });
247
+ });
248
+ /**
249
+ * Picks files, and hands them over as `File`s.
250
+ */
251
+ const FileInput = {
252
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ accept, multiple = false, disabled = false, onChange, onReject, children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, ...props }, ref) => {
253
+ const inputId = (0, react.useId)();
254
+ const context = (0, react.useMemo)(() => ({
255
+ inputId,
256
+ disabled
257
+ }), [inputId, disabled]);
258
+ function handleChange(event) {
259
+ const picked = Array.from(event.target.files ?? []);
260
+ event.target.value = "";
261
+ const accepted = [];
262
+ const rejected = [];
263
+ for (const file of picked) if ((0, _tremolo_ui_dom.matchesAccept)(file, accept)) accepted.push(file);
264
+ else rejected.push(file);
265
+ if (rejected.length > 0) onReject?.(rejected);
266
+ if (accepted.length > 0) onChange?.(accepted);
267
+ }
268
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FileInputProvider, {
269
+ value: context,
270
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
271
+ ref,
272
+ "data-disabled": disabled ? "" : void 0,
273
+ ...props,
274
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
275
+ id: inputId,
276
+ type: "file",
277
+ accept,
278
+ multiple,
279
+ disabled,
280
+ style: visuallyHiddenStyle,
281
+ "aria-label": ariaLabel,
282
+ "aria-labelledby": ariaLabelledby,
283
+ "aria-describedby": ariaDescribedby,
284
+ onChange: handleChange
285
+ }), children]
286
+ })
287
+ });
288
+ }),
289
+ Trigger
290
+ };
291
+ //#endregion
292
+ //#region src/hooks/_internal/useCheckSteps.ts
293
+ /** Positions probed across the travel. The ends are left out so that the
294
+ * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
295
+ const PROBES = [
296
+ .1,
297
+ .2,
298
+ .3,
299
+ .4,
300
+ .5,
301
+ .6,
302
+ .7,
303
+ .8,
304
+ .9
305
+ ];
306
+ const NONE = {
307
+ shiftKey: false,
308
+ altKey: false,
309
+ ctrlKey: false,
310
+ metaKey: false
311
+ };
312
+ const MODIFIER_STATE = {
313
+ shift: {
314
+ ...NONE,
315
+ shiftKey: true
316
+ },
317
+ alt: {
318
+ ...NONE,
319
+ altKey: true
320
+ },
321
+ ctrl: {
322
+ ...NONE,
323
+ ctrlKey: true
324
+ },
325
+ meta: {
326
+ ...NONE,
327
+ metaKey: true
328
+ }
329
+ };
330
+ /** Each entry of a modifier-aware input option, as a name and held keys. */
331
+ function entries(options) {
332
+ if (Array.isArray(options)) return [{
333
+ name: "",
334
+ modifiers: NONE
335
+ }];
336
+ return Object.keys(options).map((key) => ({
337
+ name: key === "default" ? "" : key,
338
+ modifiers: MODIFIER_STATE[key] ?? NONE
339
+ }));
340
+ }
341
+ /**
342
+ * Press every entry of `options` at nine points along the travel and report
343
+ * whether anything came of it.
344
+ *
345
+ * Run against `applyDelta` itself rather than against a reading of `step`:
346
+ * the whole point is that the amount, the step and the scale interact, and
347
+ * the pipeline is the only thing that knows how.
348
+ */
349
+ function probe(options, range, format) {
350
+ const { min, max, scale = _tremolo_ui_functions.linearScale } = range;
351
+ const outcome = {
352
+ moved: false,
353
+ visible: false
354
+ };
355
+ for (const { modifiers } of entries(options)) for (const position of PROBES) {
356
+ const value = scale.denormalize(position, min, max);
357
+ const up = (0, _tremolo_ui_dom.applyDelta)(value, 1, options, range, modifiers);
358
+ const down = (0, _tremolo_ui_dom.applyDelta)(value, -1, options, range, modifiers);
359
+ if (up !== value || down !== value) outcome.moved = true;
360
+ if (!format) continue;
361
+ const shown = format(value);
362
+ if (format(up) !== shown || format(down) !== shown) outcome.visible = true;
363
+ }
364
+ return outcome;
365
+ }
366
+ /**
367
+ * Warn, in development only, when a key press or a wheel notch cannot produce
368
+ * a change the user can see.
369
+ *
370
+ * Two settings that are each fine on their own can cancel out, and nothing
371
+ * fails when they do — the control simply sits there:
372
+ *
373
+ * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
374
+ * `step={1}` rounds every press straight back to where it started
375
+ * - **the display coarser than the amount.** A `format` showing two decimals
376
+ * of a kHz value cannot show a press worth 1 Hz
377
+ *
378
+ * The second is only reported when the press is invisible at *every* point
379
+ * along the travel. A display that rounds is a deliberate choice and is
380
+ * normally right — it is being too coarse everywhere that makes it a mistake.
381
+ *
382
+ * @internal
383
+ */
384
+ function useCheckSteps({ component, axis, range, keyboard, wheel, format }) {
385
+ const { min, max, step, scale } = range ?? {};
386
+ const where = axis ? `${component} (${axis})` : component;
387
+ (0, react.useEffect)(() => {
388
+ try {
389
+ if (process.env.NODE_ENV === "production") return;
390
+ if (!range || min === void 0 || max === void 0 || !(min < max)) return;
391
+ for (const [name, options] of [["keyboard", keyboard], ["wheel", wheel]]) {
392
+ if (!options) continue;
393
+ const { moved, visible } = probe(options, range, format);
394
+ if (!moved) console.warn(`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` + (step !== void 0 ? ` Each press is smaller than \`step\` (${step}), so it rounds` : " Each press rounds") + " straight back to where it started.");
395
+ else if (format && !visible) console.warn(`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\` shows the same text before and after, everywhere in the range. The display is too coarse for it to be seen.`);
396
+ }
397
+ } catch {}
398
+ }, [
399
+ where,
400
+ min,
401
+ max,
402
+ step,
403
+ scale,
404
+ keyboard,
405
+ wheel
406
+ ]);
407
+ }
408
+ //#endregion
114
409
  //#region src/hooks/useDragValue.ts
115
410
  /**
116
411
  * Drive a value with a pointer drag.
@@ -118,7 +413,7 @@ function useCallbackRef(callback, deps = []) {
118
413
  * @returns a ref callback for the element that starts the drag, and whether a drag is in progress
119
414
  */
120
415
  function useDragValue(options) {
121
- const { axis, baseElementRef, pixelRange, updateOnPointerDown, threshold, cursor } = options;
416
+ const { axis, baseElementRef, pixelRange, updateOnPointerDown, threshold, cursor, pointerLock } = options;
122
417
  if (!baseElementRef && !options.getValue) throw new Error("useDragValue: give either baseElementRef or getValue, so that the drag has something to move");
123
418
  const [dragging, setDragging] = (0, react.useState)(false);
124
419
  const valueGetter = useCallbackRef(options.getValue);
@@ -133,19 +428,24 @@ function useDragValue(options) {
133
428
  if (!node) return;
134
429
  const instance = (0, _tremolo_ui_dom.createDragValue)(node, {
135
430
  axis: latest.current.axis,
136
- mapping: baseElementRef ? (0, _tremolo_ui_dom.elementMapping)(() => baseElementRef.current) : (0, _tremolo_ui_dom.relativeMapping)({ pixelRange: [pixelRangeX ?? 100, pixelRangeY ?? 100] }),
431
+ mapping: baseElementRef ? (0, _tremolo_ui_dom.elementMapping)(() => baseElementRef.current, { sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1 }) : (0, _tremolo_ui_dom.relativeMapping)({
432
+ pixelRange: [pixelRangeX ?? 100, pixelRangeY ?? 100],
433
+ sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1
434
+ }),
137
435
  getValue: () => valueGetter() ?? [0, 0],
138
436
  updateOnPointerDown: latest.current.updateOnPointerDown,
139
437
  threshold: latest.current.threshold,
140
438
  cursor: latest.current.cursor,
141
- onChange: (value) => changeHandler(value),
142
- onDragStart: (value) => {
439
+ pointerLock: latest.current.pointerLock,
440
+ shouldStart: (event) => latest.current.shouldStart?.(event) ?? true,
441
+ onChange: (value, state) => changeHandler(value, state),
442
+ onDragStart: (value, state) => {
143
443
  setDragging(true);
144
- dragStartHandler(value);
444
+ dragStartHandler(value, state);
145
445
  },
146
- onDragEnd: (value) => {
446
+ onDragEnd: (value, state) => {
147
447
  setDragging(false);
148
- dragEndHandler(value);
448
+ dragEndHandler(value, state);
149
449
  }
150
450
  });
151
451
  instanceRef.current = instance;
@@ -169,7 +469,8 @@ function useDragValue(options) {
169
469
  axis,
170
470
  updateOnPointerDown,
171
471
  threshold,
172
- cursor
472
+ cursor,
473
+ pointerLock
173
474
  });
174
475
  });
175
476
  return {
@@ -203,50 +504,89 @@ function useWheel(onWheel, { requireFocus = false, target } = {}) {
203
504
  return setNode;
204
505
  }
205
506
  //#endregion
206
- //#region src/components/_util/index.ts
207
- function addUserSelectNone() {
208
- window.document.body.classList.add("tremolo-user-select-none");
209
- }
210
- function removeUserSelectNone() {
211
- window.document.body.classList.remove("tremolo-user-select-none");
212
- }
507
+ //#region src/input-event.ts
508
+ /**
509
+ * The keyboard amount used by Knob, NumberInput, Slider, and XYPad by default:
510
+ * 1 per press in the units of the value, and 0.1 with shift held. That is one
511
+ * `step` only while `step` is 1; a coarser `step` rounds 1 straight back, which
512
+ * `useCheckSteps` warns about.
513
+ *
514
+ * A modifier entry is not snapped to `step`, which is what lets the finer
515
+ * amount move at all — see `applyDelta` in `@tremolo-ui/dom`.
516
+ */
517
+ const DEFAULT_KEYBOARD_OPTIONS = {
518
+ default: ["raw", 1],
519
+ shift: ["raw", .1]
520
+ };
521
+ /**
522
+ * The wheel amount used by Knob, NumberInput, Slider, and XYPad by default.
523
+ *
524
+ * Browsers turn shift+wheel into horizontal scrolling, which empties `deltaY`
525
+ * and fills `deltaX`, so no modifier is bound here.
526
+ */
527
+ const DEFAULT_WHEEL_OPTIONS = ["raw", 1];
528
+ /**
529
+ * The drag sensitivity used by value controls by default. Shift makes the
530
+ * same movement cover a tenth of the range, matching the arrow keys.
531
+ */
532
+ const DEFAULT_DRAG_SENSITIVITY = {
533
+ default: 1,
534
+ shift: .1
535
+ };
213
536
  //#endregion
214
- //#region src/components/_util/composeRefs.tsx
537
+ //#region src/components/_util/css-length.ts
215
538
  /**
216
- * Set a given ref to a given value
217
- * This utility takes care of different types of refs: callback refs and RefObject(s)
539
+ * A number as a CSS length, for a custom property.
540
+ *
541
+ * React appends `px` to a bare number for the properties it knows take a
542
+ * length, and a custom property is never one of them: `--size: 50` would come
543
+ * out as the invalid `50`. Anything already a string is passed through, so
544
+ * `'3rem'` and `'100%'` still work.
218
545
  */
219
- function setRef(ref, value) {
220
- if (typeof ref === "function") return ref(value);
221
- else if (ref !== null && ref !== void 0) ref.current = value;
546
+ function cssLength(value) {
547
+ return typeof value === "number" ? `${value}px` : value;
222
548
  }
549
+ //#endregion
550
+ //#region src/components/_util/Placement.tsx
551
+ /**
552
+ * The subcomponent that established the current placement, if any.
553
+ *
554
+ * @internal
555
+ */
556
+ const PlacementContext = /* @__PURE__ */ (0, react.createContext)(null);
223
557
  /**
224
- * A utility to compose multiple refs together
225
- * Accepts callback refs and RefObject(s)
558
+ * Marks its children as being inside `name`, for {@link useCheckPlacement}.
559
+ *
560
+ * @internal
226
561
  */
227
- function composeRefs(...refs) {
228
- return (node) => {
229
- let hasCleanup = false;
230
- const cleanups = refs.map((ref) => {
231
- const cleanup = setRef(ref, node);
232
- if (!hasCleanup && typeof cleanup == "function") hasCleanup = true;
233
- return cleanup;
234
- });
235
- if (hasCleanup) return () => {
236
- for (let i = 0; i < cleanups.length; i++) {
237
- const cleanup = cleanups[i];
238
- if (typeof cleanup == "function") cleanup();
239
- else setRef(refs[i], null);
240
- }
241
- };
242
- };
562
+ function Placement({ name, children }) {
563
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlacementContext.Provider, {
564
+ value: name,
565
+ children
566
+ });
243
567
  }
244
568
  /**
245
- * A custom hook that composes multiple refs
246
- * Accepts callback refs and RefObject(s)
569
+ * Warn, in development only, when `child` is not rendered inside `parent`.
570
+ *
571
+ * A compound component renders its children exactly as they are composed, so a
572
+ * subcomponent put at the wrong level still renders — it only loses whatever
573
+ * its layout depended on, and does so silently. `Slider.Thumb` outside
574
+ * `Slider.Track` keeps its `position: absolute` but resolves it against the
575
+ * page rather than the track, and neither the build nor the tests notice.
576
+ *
577
+ * @internal
247
578
  */
248
- function useComposedRefs(...refs) {
249
- return (0, react.useCallback)(composeRefs(...refs), refs);
579
+ function useCheckPlacement(child, parent) {
580
+ const found = (0, react.useContext)(PlacementContext);
581
+ (0, react.useEffect)(() => {
582
+ try {
583
+ if (process.env.NODE_ENV !== "production" && found !== parent) console.warn(`[tremolo-ui] ${child} has to be rendered inside ${parent}` + (found === null ? "." : `, but it is inside ${found}.`) + " It renders either way, so nothing fails — its position just comes out wrong.");
584
+ } catch {}
585
+ }, [
586
+ found,
587
+ child,
588
+ parent
589
+ ]);
250
590
  }
251
591
  /**
252
592
  * 角度から円弧上の点を求める。
@@ -265,6 +605,19 @@ function arcRadius(strokeWidth) {
265
605
  const width = typeof strokeWidth === "number" ? strokeWidth : Number.parseFloat(String(strokeWidth));
266
606
  return Number.isFinite(width) ? 50 - width / 2 : 50;
267
607
  }
608
+ /** Build an SVG path for an arc, splitting full turns into drawable segments. */
609
+ function arcPath(startAngle, endAngle, radius) {
610
+ const start = pointOnArc(startAngle, radius);
611
+ const sweep = endAngle - startAngle;
612
+ const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180));
613
+ const segmentSweep = sweep / segmentCount;
614
+ let path = `M ${start.x} ${start.y}`;
615
+ for (let i = 1; i <= segmentCount; i += 1) {
616
+ const end = pointOnArc(startAngle + segmentSweep * i, radius);
617
+ path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`;
618
+ }
619
+ return path;
620
+ }
268
621
  /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
269
622
  function calcAngles({ value, min, max, scale, startValue, angleRange }) {
270
623
  const p = scale.normalize(value, min, max);
@@ -278,7 +631,7 @@ function calcAngles({ value, min, max, scale, startValue, angleRange }) {
278
631
  r4: angleRange / 2
279
632
  };
280
633
  }
281
- const KnobContext = (0, react.createContext)(null);
634
+ const KnobContext = /* @__PURE__ */ (0, react.createContext)(null);
282
635
  const KnobProvider = KnobContext.Provider;
283
636
  function useKnobContext(selector) {
284
637
  const context = (0, react.useContext)(KnobContext);
@@ -288,14 +641,13 @@ function useKnobContext(selector) {
288
641
  //#endregion
289
642
  //#region src/components/Knob/ActiveLine.tsx
290
643
  function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...props }) {
644
+ useCheckPlacement("Knob.ActiveLine", "Knob.SVGRoot");
291
645
  const r2 = useKnobContext((s) => s.r2);
292
646
  const r3 = useKnobContext((s) => s.r3);
293
647
  const radius = arcRadius(strokeWidth);
294
- const start = pointOnArc(r2, radius);
295
- const end = pointOnArc(r3, radius);
296
648
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
297
- className: (0, clsx.default)("tremolo-knob-active-line", className),
298
- d: `M ${start.x} ${start.y} A ${radius} ${radius} -135 ${r3 - r2 > 180 ? 1 : 0} 1 ${end.x} ${end.y}`,
649
+ className,
650
+ d: arcPath(r2, r3, radius),
299
651
  fill: "none",
300
652
  stroke,
301
653
  strokeWidth,
@@ -305,6 +657,7 @@ function ActiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...pr
305
657
  //#endregion
306
658
  //#region src/components/Knob/InactiveLine.tsx
307
659
  function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...props }) {
660
+ useCheckPlacement("Knob.InactiveLine", "Knob.SVGRoot");
308
661
  const min = useKnobContext((s) => s.min);
309
662
  const max = useKnobContext((s) => s.max);
310
663
  const startValue = useKnobContext((s) => s.startValue);
@@ -313,20 +666,16 @@ function InactiveLine({ stroke = "currentColor", strokeWidth = 6, className, ...
313
666
  const r3 = useKnobContext((s) => s.r3);
314
667
  const r4 = useKnobContext((s) => s.r4);
315
668
  const radius = arcRadius(strokeWidth);
316
- const p1 = pointOnArc(r1, radius);
317
- const p2 = pointOnArc(r2, radius);
318
- const p3 = pointOnArc(r3, radius);
319
- const p4 = pointOnArc(r4, radius);
320
669
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [startValue > min && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
321
- className: (0, clsx.default)("tremolo-knob-inactive-line", className),
322
- d: `M ${p1.x} ${p1.y} A ${radius} ${radius} -135 ${r2 - r1 > 180 ? 1 : 0} 1 ${p2.x} ${p2.y}`,
670
+ className,
671
+ d: arcPath(r1, r2, radius),
323
672
  fill: "none",
324
673
  stroke,
325
674
  strokeWidth,
326
675
  ...props
327
676
  }), startValue < max && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
328
- className: (0, clsx.default)("tremolo-knob-inactive-line", className),
329
- d: `M ${p3.x} ${p3.y} A ${radius} ${radius} -135 ${r4 - r3 > 180 ? 1 : 0} 1 ${p4.x} ${p4.y}`,
677
+ className,
678
+ d: arcPath(r3, r4, radius),
330
679
  fill: "none",
331
680
  stroke,
332
681
  strokeWidth,
@@ -343,17 +692,21 @@ function SVGRoot({ children, style, ...props }) {
343
692
  ...style
344
693
  },
345
694
  ...props,
346
- children
695
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
696
+ name: "Knob.SVGRoot",
697
+ children
698
+ })
347
699
  });
348
700
  }
349
701
  //#endregion
350
702
  //#region src/components/Knob/Thumb.tsx
351
703
  function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor", thumbSize = 84, thumbLineWeight = 6, thumbLineLength = 35, classes, ...props }) {
704
+ useCheckPlacement("Knob.Thumb", "Knob.SVGRoot");
352
705
  const angleRange = useKnobContext((s) => s.angleRange);
353
706
  const p = useKnobContext((s) => s.p);
354
707
  const r1 = useKnobContext((s) => s.r1);
355
708
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
356
- className: (0, clsx.default)("tremolo-knob-thumb", classes?.thumb),
709
+ className,
357
710
  ...props,
358
711
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
359
712
  cx: "50%",
@@ -361,7 +714,7 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
361
714
  r: `${thumbSize / 2}%`,
362
715
  fill: thumb
363
716
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
364
- className: (0, clsx.default)("tremolo-knob-thumb-line", classes?.thumbLine),
717
+ className: classes?.thumbLine,
365
718
  x1: "50%",
366
719
  y1: `${(100 - (0, _tremolo_ui_functions.clamp)(thumbSize, 0, 100)) / 2}%`,
367
720
  x2: "50%",
@@ -377,10 +730,7 @@ function Thumb$2({ className, thumb = "currentColor", thumbLine = "currentColor"
377
730
  }
378
731
  //#endregion
379
732
  //#region src/components/Knob/index.tsx
380
- const defaultExternalStyles$2 = {
381
- userSelectNone: true,
382
- cursor: "grabbing"
383
- };
733
+ const defaultExternalStyles$2 = { cursor: "grabbing" };
384
734
  /**
385
735
  * The wheel only acts while the focus is inside, so that scrolling a page past
386
736
  * the control does not change its value.
@@ -390,12 +740,13 @@ const WHEEL_OPTIONS$2 = { requireFocus: true };
390
740
  * Interactive rotary knob component implemented in SVG.
391
741
  */
392
742
  const Knob = {
393
- Root: (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
743
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, defaultValue = min, startValue = min, size, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, enableDoubleClickDefault = true, disabled = false, readonly = false, angleRange = 270, onChange, onKeyDown, onPointerDown, onDoubleClick, className, style, children, ...props }, forwardedRef) => {
394
744
  const elmRef = (0, react.useRef)(null);
395
745
  const externalStyles = {
396
746
  ...defaultExternalStyles$2,
397
747
  ..._externalStyles
398
748
  };
749
+ const inactive = disabled || readonly;
399
750
  const range = (0, react.useMemo)(() => ({
400
751
  min,
401
752
  max,
@@ -407,8 +758,14 @@ const Knob = {
407
758
  step,
408
759
  scale
409
760
  ]);
761
+ useCheckSteps({
762
+ component: "Knob",
763
+ range,
764
+ keyboard,
765
+ wheel
766
+ });
410
767
  const handleKeyDown = (0, react.useCallback)((event) => {
411
- if (!keyboard || !onChange || readonly) return;
768
+ if (!keyboard || !onChange || inactive) return;
412
769
  const key = event.key;
413
770
  if ([
414
771
  "ArrowRight",
@@ -417,12 +774,12 @@ const Knob = {
417
774
  "ArrowDown"
418
775
  ].includes(key)) {
419
776
  event.preventDefault();
420
- onChange((0, _tremolo_ui_functions.applyDelta)(value, key == "ArrowRight" || key == "ArrowUp" ? 1 : -1, keyboard, range));
777
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, key === "ArrowRight" || key === "ArrowUp" ? 1 : -1, keyboard, range, event));
421
778
  }
422
779
  }, [
423
780
  keyboard,
424
781
  onChange,
425
- readonly,
782
+ inactive,
426
783
  value,
427
784
  range
428
785
  ]);
@@ -432,25 +789,23 @@ const Knob = {
432
789
  reverse: true
433
790
  }], [range]),
434
791
  getValue: () => [value, value],
792
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
435
793
  threshold: 1,
436
- cursor: readonly ? void 0 : externalStyles.cursor,
794
+ cursor: inactive ? void 0 : externalStyles.cursor,
795
+ pointerLock: inactive ? false : pointerLock,
796
+ shouldStart: () => !inactive,
437
797
  onChange: (v) => {
438
- if (readonly) return;
798
+ if (inactive) return;
439
799
  onChange?.(v[1]);
440
- },
441
- onDragStart: () => {
442
- if (externalStyles.userSelectNone) addUserSelectNone();
443
- },
444
- onDragEnd: () => {
445
- if (externalStyles.userSelectNone) removeUserSelectNone();
446
800
  }
447
801
  });
448
- const rootRefCallback = useComposedRefs(elmRef, dragRefCallback, useWheel((event) => {
449
- if (!wheel || readonly) return;
802
+ const wheelRefCallback = useWheel((event) => {
803
+ if (!wheel || inactive) return;
450
804
  event.preventDefault();
451
- if (!onChange || event.deltaY == 0) return;
452
- onChange((0, _tremolo_ui_functions.applyDelta)(value, -Math.sign(event.deltaY), wheel, range));
453
- }, WHEEL_OPTIONS$2));
805
+ if (!onChange || event.deltaY === 0) return;
806
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, -Math.sign(event.deltaY), wheel, range, event));
807
+ }, WHEEL_OPTIONS$2);
808
+ const rootRefCallback = require_compose_refs.useComposedRefs(elmRef, dragRefCallback, wheelRefCallback);
454
809
  const context = (0, react.useMemo)(() => {
455
810
  const config = {
456
811
  value,
@@ -477,34 +832,35 @@ const Knob = {
477
832
  (0, react.useImperativeHandle)(forwardedRef, () => {
478
833
  return {
479
834
  focus() {
480
- elmRef.current?.focus();
835
+ if (!disabled) elmRef.current?.focus();
481
836
  },
482
837
  blur() {
483
838
  elmRef.current?.blur();
484
839
  }
485
840
  };
486
- }, []);
841
+ }, [disabled]);
487
842
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KnobProvider, {
488
843
  value: context,
489
844
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
490
845
  ref: rootRefCallback,
491
- className: (0, clsx.default)("tremolo-knob", className),
492
- tabIndex: 0,
846
+ className,
847
+ tabIndex: disabled ? -1 : 0,
493
848
  role: "slider",
494
849
  "aria-valuenow": value,
495
850
  "aria-valuemin": min,
496
851
  "aria-valuemax": max,
497
852
  "aria-disabled": disabled,
498
853
  "aria-readonly": readonly,
499
- "data-dragging": dragging,
854
+ "data-disabled": disabled ? "" : void 0,
855
+ "data-readonly": readonly ? "" : void 0,
856
+ "data-dragging": dragging ? "" : void 0,
500
857
  style: {
501
- width: size,
502
- height: size,
858
+ "--knob-size": cssLength(size),
503
859
  ...style
504
860
  },
505
861
  onPointerDown,
506
862
  onDoubleClick: (event) => {
507
- if (enableDoubleClickDefault && onChange) onChange(defaultValue);
863
+ if (!inactive && enableDoubleClickDefault && onChange) onChange(defaultValue);
508
864
  onDoubleClick?.(event);
509
865
  },
510
866
  onKeyDown: (event) => {
@@ -523,14 +879,14 @@ const Knob = {
523
879
  };
524
880
  //#endregion
525
881
  //#region src/components/NumberInput/context.tsx
526
- const NumberInputContext = (0, react.createContext)(null);
882
+ const NumberInputContext = /* @__PURE__ */ (0, react.createContext)(null);
527
883
  const NumberInputProvider = NumberInputContext.Provider;
528
884
  function useNumberInputContext(selector) {
529
885
  const context = (0, react.useContext)(NumberInputContext);
530
886
  if (!context) throw new Error("Missing NumberInputContext.Provider in the tree");
531
887
  return selector ? selector(context) : context;
532
888
  }
533
- const StepperContext = (0, react.createContext)(null);
889
+ const StepperContext = /* @__PURE__ */ (0, react.createContext)(null);
534
890
  const StepperProvider = StepperContext.Provider;
535
891
  function useStepperContext() {
536
892
  return (0, react.useContext)(StepperContext);
@@ -539,22 +895,26 @@ function useStepperContext() {
539
895
  //#region src/hooks/useEventListener.ts
540
896
  function useEventListener(target, event, handler, options) {
541
897
  const listener = useCallbackRef(handler);
898
+ const cleanupRef = (0, react.useRef)(() => {});
542
899
  (0, react.useEffect)(() => {
543
900
  const node = typeof target === "function" ? target() : target ?? document;
544
901
  if (!node) return;
545
902
  node.addEventListener(event, listener, options);
546
- return () => {
903
+ const cleanup = () => {
547
904
  node.removeEventListener(event, listener, options);
548
905
  };
906
+ cleanupRef.current = cleanup;
907
+ return () => {
908
+ cleanup();
909
+ if (cleanupRef.current === cleanup) cleanupRef.current = () => {};
910
+ };
549
911
  }, [
550
912
  event,
551
913
  target,
552
914
  options,
553
915
  listener
554
916
  ]);
555
- return () => {
556
- (typeof target === "function" ? target() : target ?? document)?.removeEventListener(event, listener, options);
557
- };
917
+ return (0, react.useCallback)(() => cleanupRef.current(), []);
558
918
  }
559
919
  //#endregion
560
920
  //#region src/hooks/useInterval.ts
@@ -578,47 +938,61 @@ function useInterval(callback, delay) {
578
938
  function useLongPress(callback, initialDelay = 500, interval = 40) {
579
939
  const [pressed, setPressed] = (0, react.useState)(false);
580
940
  const [delay, setDelay] = (0, react.useState)(initialDelay);
941
+ const activePointerId = (0, react.useRef)(null);
942
+ const isPressed = (0, react.useRef)(false);
581
943
  useInterval(() => {
582
944
  callback();
583
945
  setDelay(interval);
584
946
  }, pressed ? delay : null);
585
- useEventListener(globalThis.window, "pointerup", () => {
947
+ const stop = (0, react.useCallback)((pointerId) => {
948
+ if (!isPressed.current) return;
949
+ if (pointerId !== void 0 && activePointerId.current !== null && activePointerId.current !== pointerId) return;
950
+ isPressed.current = false;
951
+ activePointerId.current = null;
586
952
  setPressed(false);
587
953
  setDelay(initialDelay);
954
+ }, [initialDelay]);
955
+ useEventListener(globalThis.window, "pointerup", (event) => {
956
+ stop(event.pointerId);
588
957
  });
589
- return (0, react.useCallback)(() => {
958
+ useEventListener(globalThis.window, "pointercancel", (event) => {
959
+ stop(event.pointerId);
960
+ });
961
+ useEventListener(globalThis.window, "blur", () => {
962
+ stop();
963
+ });
964
+ return (0, react.useCallback)((event) => {
965
+ if (isPressed.current || event && event.button !== 0) return;
966
+ isPressed.current = true;
967
+ activePointerId.current = event?.pointerId ?? null;
590
968
  callback();
591
969
  setPressed(true);
592
970
  }, [callback]);
593
971
  }
594
972
  //#endregion
595
- //#region src/components/NumberInput/stepperButton.tsx
973
+ //#region src/components/NumberInput/StepperButton.tsx
596
974
  /**
597
975
  * Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
598
976
  * in which way they move the value and which arrow they draw.
599
977
  */
600
978
  function StepperButton({ direction, variant, icon, className, style, children, onPointerDown, ...props }) {
601
- const { step, readonly, atMin, atMax, nudge } = useNumberInputContext();
979
+ const { step, disabled, readonly, atMin, atMax, nudge } = useNumberInputContext();
602
980
  const stepper = useStepperContext();
603
- const press = useLongPress((0, react.useCallback)(() => {
981
+ const press = useLongPress(() => {
604
982
  if (stepper?.draggingRef.current) return;
605
983
  nudge(direction, ["raw", step]);
606
- }, [
607
- stepper,
608
- nudge,
609
- direction,
610
- step
611
- ]));
984
+ });
612
985
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
613
- className: (0, clsx.default)(`tremolo-number-input-${variant}-stepper`, className),
986
+ className,
614
987
  role: "button",
615
988
  tabIndex: -1,
616
989
  "aria-label": direction > 0 ? "Increment" : "Decrement",
617
- "aria-disabled": direction > 0 ? atMax : atMin,
618
- "aria-readonly": readonly,
990
+ "aria-disabled": disabled || (direction > 0 ? atMax : atMin),
991
+ "data-disabled": disabled || (direction > 0 ? atMax : atMin) ? "" : void 0,
992
+ "data-readonly": readonly ? "" : void 0,
619
993
  style,
620
994
  onPointerDown: (event) => {
621
- if (!readonly) press();
995
+ if (!disabled && !readonly) press(event);
622
996
  onPointerDown?.(event);
623
997
  },
624
998
  ...props,
@@ -627,7 +1001,10 @@ function StepperButton({ direction, variant, icon, className, style, children, o
627
1001
  }
628
1002
  function StepperArrow({ up }) {
629
1003
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
630
- className: "tremolo-number-input-stepper-icon",
1004
+ style: {
1005
+ width: "var(--stepper-icon-size)",
1006
+ height: "var(--stepper-icon-size)"
1007
+ },
631
1008
  viewBox: "0 0 24 24",
632
1009
  fill: "none",
633
1010
  stroke: "currentColor",
@@ -663,56 +1040,111 @@ function IncrementStepper(props) {
663
1040
  //#region src/components/NumberInput/InputField.tsx
664
1041
  /** The leading number of the displayed text, whatever the format put around it. */
665
1042
  const NUMBER_PREFIX = /^\s*-?[\d.,]*/;
1043
+ const numberEnd = (text) => text.match(NUMBER_PREFIX)?.[0].length ?? text.length;
1044
+ /**
1045
+ * The index the caret is measured against: the decimal point, or where one
1046
+ * would go if the number has none.
1047
+ *
1048
+ * Measuring from an end instead would slide the caret across a digit whenever
1049
+ * the number changed length — `9.9` to `10.0` gains a character in front, `10`
1050
+ * to `9` loses one — which is exactly what stepping does.
1051
+ */
1052
+ const decimalAnchor = (text) => {
1053
+ const dot = text.indexOf(".");
1054
+ return dot === -1 ? numberEnd(text) : dot;
1055
+ };
666
1056
  /**
667
1057
  * The text field of a `NumberInput`, and the only place the value can be typed.
668
1058
  *
669
1059
  * While the user types, their own text stands rather than `format(value)`, so
670
1060
  * that a half-finished entry is not rewritten under the caret.
1061
+ *
1062
+ * How it behaves on focus, Enter and the arrow keys is set on `Root`
1063
+ * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
671
1064
  */
672
- function InputField({ selectOnFocus = "none", blurOnEnter = true, className, style, ref, onFocus, onBlur, onKeyDown, ...props }) {
673
- const { value, min, max, step, disabled, readonly, keyboard, text, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
1065
+ const InputField = /* @__PURE__ */ (0, react.forwardRef)(function InputField({ className, style, onFocus, onBlur, onKeyDown, ...props }, forwardedRef) {
1066
+ const { value, min, max, step, disabled, readonly, keyboard, selectOnFocus, unformatOnFocus, keepCaretOnStep, blurOnEnter, text, editing, outOfRange, setDraft, commitDraft, nudge, inputRef } = useNumberInputContext();
1067
+ const [focused, setFocused] = (0, react.useState)(false);
1068
+ const shown = unformatOnFocus && focused && !editing ? String(value) : text;
1069
+ (0, react.useEffect)(() => {
1070
+ if (!focused || selectOnFocus === "none") return;
1071
+ const input = inputRef.current;
1072
+ if (!input) return;
1073
+ if (selectOnFocus === "all") input.setSelectionRange(0, input.value.length);
1074
+ else input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
1075
+ }, [
1076
+ focused,
1077
+ selectOnFocus,
1078
+ inputRef
1079
+ ]);
1080
+ /**
1081
+ * Where to put the caret once the stepped value has been rendered, and the
1082
+ * text it was measured against.
1083
+ */
1084
+ const caretAfterStep = (0, react.useRef)(null);
1085
+ (0, react.useLayoutEffect)(() => {
1086
+ const pending = caretAfterStep.current;
1087
+ if (!pending) return;
1088
+ caretAfterStep.current = null;
1089
+ const input = inputRef.current;
1090
+ if (!input || input.value === pending.from) return;
1091
+ const place = decimalAnchor(input.value) + pending.offset;
1092
+ const caret = Math.max(0, Math.min(place, numberEnd(input.value)));
1093
+ input.setSelectionRange(caret, caret);
1094
+ });
1095
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, inputRef);
674
1096
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
675
- ref: useComposedRefs(ref, inputRef),
676
- className: (0, clsx.default)("tremolo-number-input-field", className),
1097
+ ref: composedRef,
1098
+ className,
677
1099
  type: "text",
678
1100
  inputMode: "decimal",
679
1101
  role: "spinbutton",
680
- value: text,
1102
+ value: shown,
1103
+ disabled,
681
1104
  readOnly: readonly,
682
1105
  "aria-disabled": disabled,
683
1106
  "aria-readonly": readonly,
1107
+ "data-disabled": disabled ? "" : void 0,
1108
+ "data-readonly": readonly ? "" : void 0,
684
1109
  "aria-valuenow": value,
685
1110
  "aria-valuemin": min,
686
1111
  "aria-valuemax": max,
687
1112
  "aria-valuetext": text,
688
1113
  step,
689
- "data-out-of-range": outOfRange,
1114
+ "data-out-of-range": outOfRange ? "" : void 0,
690
1115
  style,
691
- onChange: (event) => setDraft(event.currentTarget.value),
1116
+ onChange: (event) => {
1117
+ caretAfterStep.current = null;
1118
+ setDraft(event.currentTarget.value);
1119
+ },
692
1120
  onFocus: (event) => {
693
- const input = event.currentTarget;
694
- if (selectOnFocus == "all") input.setSelectionRange(0, input.value.length);
695
- else if (selectOnFocus == "number") input.setSelectionRange(0, input.value.match(NUMBER_PREFIX)?.[0].length ?? 0);
1121
+ setFocused(true);
696
1122
  onFocus?.(event);
697
1123
  },
698
1124
  onBlur: (event) => {
1125
+ setFocused(false);
699
1126
  commitDraft();
700
1127
  onBlur?.(event);
701
1128
  },
702
1129
  onKeyDown: (event) => {
703
1130
  const key = event.key;
704
- if (key == "Enter") {
1131
+ if (key === "Enter") {
705
1132
  commitDraft();
706
1133
  if (blurOnEnter) event.currentTarget.blur();
707
- } else if (keyboard && !readonly && (key == "ArrowUp" || key == "ArrowDown")) {
1134
+ } else if (keyboard && !disabled && !readonly && (key === "ArrowUp" || key === "ArrowDown") && !event.nativeEvent.isComposing) {
708
1135
  event.preventDefault();
709
- nudge(key == "ArrowUp" ? 1 : -1, keyboard);
1136
+ const input = event.currentTarget;
1137
+ if (keepCaretOnStep && input.selectionStart !== null) caretAfterStep.current = {
1138
+ offset: input.selectionStart - decimalAnchor(input.value),
1139
+ from: input.value
1140
+ };
1141
+ nudge(key === "ArrowUp" ? 1 : -1, keyboard, event);
710
1142
  }
711
1143
  onKeyDown?.(event);
712
1144
  },
713
1145
  ...props
714
1146
  });
715
- }
1147
+ });
716
1148
  //#endregion
717
1149
  //#region src/hooks/useDrag.ts
718
1150
  /**
@@ -720,7 +1152,7 @@ function InputField({ selectOnFocus = "none", blurOnEnter = true, className, sty
720
1152
  *
721
1153
  * @returns a ref callback to attach to the element being dragged
722
1154
  */
723
- function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
1155
+ function useDrag({ threshold = 1, cursor, pointerLock, onDrag, onDragStart, onDragEnd }) {
724
1156
  const dragHandler = useCallbackRef(onDrag);
725
1157
  const dragStartHandler = useCallbackRef(onDragStart);
726
1158
  const dragEndHandler = useCallbackRef(onDragEnd);
@@ -730,15 +1162,17 @@ function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
730
1162
  const instance = (0, _tremolo_ui_dom.createDrag)(node, {
731
1163
  threshold,
732
1164
  cursor,
733
- onDragStart: () => dragStartHandler(),
734
- onDrag: ({ x, y, deltaX, deltaY }) => dragHandler(x, y, deltaX, deltaY),
735
- onDragEnd: () => dragEndHandler()
1165
+ pointerLock,
1166
+ onDragStart: (state) => dragStartHandler(state),
1167
+ onDrag: (state) => dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
1168
+ onDragEnd: (state) => dragEndHandler(state)
736
1169
  });
737
1170
  return () => instance.destroy();
738
1171
  }, [
739
1172
  node,
740
1173
  threshold,
741
1174
  cursor,
1175
+ pointerLock,
742
1176
  dragHandler,
743
1177
  dragStartHandler,
744
1178
  dragEndHandler
@@ -754,8 +1188,17 @@ function useDrag({ threshold = 1, cursor, onDrag, onDragStart, onDragEnd }) {
754
1188
  * The drag lives here rather than on `InputField` because `createDrag` turns
755
1189
  * off text selection on whatever element it is attached to.
756
1190
  */
757
- function Stepper({ className, style, children, ref, ...props }) {
758
- const { value, step, readonly, drag, range, changeValue } = useNumberInputContext();
1191
+ const Stepper = /* @__PURE__ */ (0, react.forwardRef)(function Stepper({ className, style, children, ...props }, forwardedRef) {
1192
+ const { value, step, disabled, readonly, drag, dragSensitivity, pointerLock, rawRange, changeValue } = useNumberInputContext();
1193
+ const inactive = disabled || readonly;
1194
+ /**
1195
+ * The sensitivity as an amount per `drag` pixels, so that the drag goes
1196
+ * through the same `applyDelta` the wheel and the arrow keys do. Carrying
1197
+ * the modifier map over rather than resolving it here is what keeps `step`
1198
+ * out of the pipeline for a modifier entry — naming one is a request to move
1199
+ * off the grid.
1200
+ */
1201
+ const dragOptions = (0, react.useMemo)(() => (0, _tremolo_ui_dom.mapModifier)(dragSensitivity, (factor) => ["raw", step * factor]), [dragSensitivity, step]);
759
1202
  /**
760
1203
  * Set once the drag has actually moved the value, so that the press-and-hold
761
1204
  * repeat of a stepper stands down and leaves the value to the drag. A ref,
@@ -768,60 +1211,90 @@ function Stepper({ className, style, children, ref, ...props }) {
768
1211
  * have been nudged once, and the drag should carry on from there.
769
1212
  */
770
1213
  const originRef = (0, react.useRef)(null);
1214
+ /**
1215
+ * Which modifier the drag is currently counting at, and where the previous
1216
+ * event was — a key produces no pointer event of its own, so a change is
1217
+ * only seen on the next move and has to be dated back to the one before it.
1218
+ */
1219
+ const factorRef = (0, react.useRef)(1);
1220
+ const previousYRef = (0, react.useRef)(0);
771
1221
  const dragRefCallback = useDrag({
772
1222
  threshold: 1,
773
- cursor: readonly ? void 0 : "ns-resize",
774
- onDragStart: () => {
1223
+ cursor: inactive ? void 0 : "ns-resize",
1224
+ pointerLock: inactive ? false : pointerLock,
1225
+ onDragStart: (state) => {
775
1226
  originRef.current = null;
776
1227
  draggingRef.current = false;
1228
+ factorRef.current = (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value;
777
1229
  },
778
- onDrag: (_x, y) => {
779
- if (readonly || drag == null) return;
1230
+ onDrag: (_x, y, _dx, _dy, state) => {
1231
+ if (inactive || drag === null) return;
780
1232
  if (!originRef.current) {
781
1233
  originRef.current = {
782
1234
  y,
783
1235
  value
784
1236
  };
1237
+ previousYRef.current = y;
785
1238
  return;
786
1239
  }
1240
+ const factor = (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value;
1241
+ if (factor !== factorRef.current) {
1242
+ originRef.current = {
1243
+ y: previousYRef.current,
1244
+ value
1245
+ };
1246
+ factorRef.current = factor;
1247
+ }
1248
+ previousYRef.current = y;
787
1249
  const steps = Math.round(-(y - originRef.current.y) / drag);
788
- if (steps == 0) return;
1250
+ if (steps === 0) return;
789
1251
  draggingRef.current = true;
790
- changeValue((0, _tremolo_ui_functions.applyDelta)(originRef.current.value, steps, ["raw", step], range));
1252
+ changeValue((0, _tremolo_ui_dom.applyDelta)(originRef.current.value, steps, dragOptions, rawRange, state.event));
791
1253
  },
792
1254
  onDragEnd: () => {
793
1255
  draggingRef.current = false;
794
1256
  }
795
1257
  });
796
- const composedRef = useComposedRefs(ref, drag != null && !readonly ? dragRefCallback : void 0);
1258
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, drag !== null && !inactive ? dragRefCallback : void 0);
1259
+ const context = (0, react.useMemo)(() => ({ draggingRef }), []);
797
1260
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StepperProvider, {
798
- value: (0, react.useMemo)(() => ({ draggingRef }), []),
1261
+ value: context,
799
1262
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
800
1263
  ref: composedRef,
801
- className: (0, clsx.default)("tremolo-number-input-stepper", className),
1264
+ className,
802
1265
  style,
803
1266
  ...props,
804
1267
  children
805
1268
  })
806
1269
  });
807
- }
1270
+ });
1271
+ //#endregion
1272
+ //#region src/components/NumberInput/index.tsx
1273
+ /**
1274
+ * A number at the start of the text, and nothing read after it. A half-typed
1275
+ * entry still yields the number in front of it, but text with no number is
1276
+ * `NaN` rather than 0, so that "unreadable" and "the user typed 0" stay apart.
1277
+ */
1278
+ const LEADING_NUMBER = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/;
1279
+ const defaultFormat = (value) => String(value);
1280
+ const defaultParse = (text) => {
1281
+ const match = text.match(LEADING_NUMBER);
1282
+ return match ? Number(match[1]) : NaN;
1283
+ };
808
1284
  /**
809
1285
  * Input with some useful functions for entering numerical values.
810
1286
  */
811
1287
  const NumberInput = {
812
- Root: (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, units, digit, format: formatProp, parse: parseProp, clampValue = true, wheel = ["raw", 1], keyboard = ["raw", 1], drag = 1, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
1288
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, format: formatProp, parse: parseProp, clampValue = true, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, drag = 1, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, pointerLock = false, selectOnFocus = "none", unformatOnFocus = false, keepCaretOnStep = false, blurOnEnter = true, disabled = false, readonly = false, className, style, onChange, children, ...props }, forwardedRef) => {
813
1289
  const inputRef = (0, react.useRef)(null);
814
1290
  /**
815
1291
  * The text being typed. The only state here: it is not a copy of `value`,
816
1292
  * but the half-finished entry that has no value to be derived from yet.
817
1293
  */
818
1294
  const [draft, setDraft] = (0, react.useState)(null);
819
- const format = (0, react.useCallback)((v) => formatProp ? formatProp(v) : (0, _tremolo_ui_functions.formatValue)(v, units, digit), [
820
- formatProp,
821
- units,
822
- digit
823
- ]);
824
- const parse = (0, react.useCallback)((t) => parseProp ? parseProp(t) : (0, _tremolo_ui_functions.parseValue)(t, units), [parseProp, units]);
1295
+ const inactive = disabled || readonly;
1296
+ const format = formatProp ?? defaultFormat;
1297
+ const parse = parseProp ?? defaultParse;
825
1298
  const range = (0, react.useMemo)(() => ({
826
1299
  min: (clampValue ? min : void 0) ?? Number.MIN_SAFE_INTEGER,
827
1300
  max: (clampValue ? max : void 0) ?? Number.MAX_SAFE_INTEGER,
@@ -834,49 +1307,90 @@ const NumberInput = {
834
1307
  step,
835
1308
  scale
836
1309
  ]);
1310
+ const rawRange = (0, react.useMemo)(() => ({
1311
+ min: (clampValue ? min : void 0) ?? -Number.MAX_VALUE,
1312
+ max: (clampValue ? max : void 0) ?? Number.MAX_VALUE,
1313
+ step,
1314
+ scale
1315
+ }), [
1316
+ clampValue,
1317
+ min,
1318
+ max,
1319
+ step,
1320
+ scale
1321
+ ]);
1322
+ useCheckSteps({
1323
+ component: "NumberInput",
1324
+ range: (0, react.useMemo)(() => min !== void 0 && max !== void 0 && min < max ? {
1325
+ min,
1326
+ max,
1327
+ step,
1328
+ scale
1329
+ } : null, [
1330
+ min,
1331
+ max,
1332
+ step,
1333
+ scale
1334
+ ]),
1335
+ keyboard,
1336
+ wheel,
1337
+ format
1338
+ });
837
1339
  const text = draft ?? format(value);
838
- const editing = draft != null;
839
- const outOfRange = !editing && (min != void 0 && value < min || max != void 0 && value > max);
1340
+ const editing = draft !== null;
1341
+ const outOfRange = !editing && (min !== void 0 && value < min || max !== void 0 && value > max);
840
1342
  const handleDraft = (0, react.useCallback)((next) => {
841
- if (readonly) return;
1343
+ if (inactive) return;
842
1344
  setDraft(next);
843
1345
  const parsed = parse(next);
844
1346
  if (Number.isFinite(parsed)) onChange?.(parsed);
845
1347
  }, [
846
- readonly,
1348
+ inactive,
847
1349
  parse,
848
1350
  onChange
849
1351
  ]);
850
1352
  const changeValue = (0, react.useCallback)((next) => {
851
- if (readonly) return;
1353
+ if (inactive) return;
852
1354
  setDraft(null);
853
- if (next != value) onChange?.(next);
1355
+ if (next !== value) onChange?.(next);
854
1356
  }, [
855
- readonly,
1357
+ inactive,
856
1358
  value,
857
1359
  onChange
858
1360
  ]);
859
1361
  const commitDraft = (0, react.useCallback)(() => {
860
- if (draft == null || readonly) return;
861
- changeValue((0, _tremolo_ui_functions.clamp)(parse(draft), range.min, range.max));
1362
+ if (draft === null || inactive) return;
1363
+ const parsed = parse(draft);
1364
+ if (!Number.isFinite(parsed)) {
1365
+ setDraft(null);
1366
+ return;
1367
+ }
1368
+ let committed = parsed;
1369
+ if (clampValue && min !== void 0) committed = Math.max(committed, min);
1370
+ if (clampValue && max !== void 0) committed = Math.min(committed, max);
1371
+ changeValue(committed);
862
1372
  }, [
863
1373
  draft,
864
- readonly,
1374
+ inactive,
865
1375
  parse,
866
- range,
1376
+ clampValue,
1377
+ min,
1378
+ max,
867
1379
  changeValue
868
1380
  ]);
869
- const nudge = (0, react.useCallback)((direction, option) => {
870
- changeValue((0, _tremolo_ui_functions.applyDelta)(value, direction, option, range));
1381
+ const nudge = (0, react.useCallback)((direction, option, modifiers) => {
1382
+ const [mode] = (0, _tremolo_ui_dom.selectModifier)(option, modifiers).value;
1383
+ changeValue((0, _tremolo_ui_dom.applyDelta)(value, direction, option, mode === "raw" ? rawRange : range, modifiers));
871
1384
  }, [
872
1385
  changeValue,
873
1386
  value,
1387
+ rawRange,
874
1388
  range
875
1389
  ]);
876
1390
  const wheelRefCallback = useWheel((event) => {
877
- if (!wheel || readonly || event.deltaY == 0) return;
1391
+ if (!wheel || inactive || event.deltaY === 0) return;
878
1392
  event.preventDefault();
879
- nudge(-Math.sign(event.deltaY), wheel);
1393
+ nudge(-Math.sign(event.deltaY), wheel, event);
880
1394
  }, { requireFocus: true });
881
1395
  const context = (0, react.useMemo)(() => ({
882
1396
  value,
@@ -888,12 +1402,19 @@ const NumberInput = {
888
1402
  readonly,
889
1403
  clampValue,
890
1404
  range,
1405
+ rawRange,
891
1406
  keyboard,
892
1407
  text,
893
1408
  editing,
894
1409
  outOfRange,
895
- atMin: clampValue && min != void 0 && value <= min,
896
- atMax: clampValue && max != void 0 && value >= max,
1410
+ dragSensitivity,
1411
+ pointerLock,
1412
+ selectOnFocus,
1413
+ unformatOnFocus,
1414
+ keepCaretOnStep,
1415
+ blurOnEnter,
1416
+ atMin: clampValue && min !== void 0 && value <= min,
1417
+ atMax: clampValue && max !== void 0 && value >= max,
897
1418
  drag,
898
1419
  format,
899
1420
  parse,
@@ -912,11 +1433,18 @@ const NumberInput = {
912
1433
  readonly,
913
1434
  clampValue,
914
1435
  range,
1436
+ rawRange,
915
1437
  keyboard,
916
1438
  text,
917
1439
  editing,
918
1440
  outOfRange,
919
1441
  drag,
1442
+ dragSensitivity,
1443
+ pointerLock,
1444
+ selectOnFocus,
1445
+ unformatOnFocus,
1446
+ keepCaretOnStep,
1447
+ blurOnEnter,
920
1448
  format,
921
1449
  parse,
922
1450
  handleDraft,
@@ -927,20 +1455,20 @@ const NumberInput = {
927
1455
  (0, react.useImperativeHandle)(forwardedRef, () => {
928
1456
  return {
929
1457
  focus() {
930
- inputRef.current?.focus();
1458
+ if (!disabled) inputRef.current?.focus();
931
1459
  },
932
1460
  blur() {
933
1461
  inputRef.current?.blur();
934
1462
  }
935
1463
  };
936
- }, []);
1464
+ }, [disabled]);
937
1465
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NumberInputProvider, {
938
1466
  value: context,
939
1467
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
940
1468
  ref: wheelRefCallback,
941
- className: (0, clsx.default)("tremolo-number-input", className),
942
- "aria-disabled": disabled,
943
- "aria-readonly": readonly,
1469
+ className,
1470
+ "data-disabled": disabled ? "" : void 0,
1471
+ "data-readonly": readonly ? "" : void 0,
944
1472
  style,
945
1473
  ...props,
946
1474
  children
@@ -953,11 +1481,12 @@ const NumberInput = {
953
1481
  DecrementStepper
954
1482
  };
955
1483
  //#endregion
956
- //#region src/components/Piano/keyboardShortcuts.ts
1484
+ //#region src/components/Piano/keyboard-shortcuts.ts
957
1485
  /**
958
1486
  * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
959
1487
  */
960
1488
  const SHORTCUTS = {
1489
+ /** Every semitone from C, over the two rows of a QWERTY keyboard. */
961
1490
  HOME_ROW: { keys: [
962
1491
  "a",
963
1492
  "w",
@@ -977,6 +1506,7 @@ const SHORTCUTS = {
977
1506
  "p",
978
1507
  ";"
979
1508
  ] },
1509
+ /** The white keys only, on the home row. Black keys have no shortcut. */
980
1510
  HOME_ROW_NATURAL: { keys: [
981
1511
  "a",
982
1512
  "",
@@ -997,26 +1527,41 @@ const SHORTCUTS = {
997
1527
  ";"
998
1528
  ] }
999
1529
  };
1530
+ //#endregion
1531
+ //#region src/components/Piano/index.tsx
1532
+ function isEditableTarget(target) {
1533
+ if (!(target instanceof HTMLElement)) return false;
1534
+ if (target.matches("input, textarea, select")) return true;
1535
+ for (let element = target; element;) {
1536
+ const contentEditable = element.getAttribute("contenteditable");
1537
+ if (contentEditable !== null) return contentEditable.toLowerCase() !== "false";
1538
+ element = element.parentElement;
1539
+ }
1540
+ return false;
1541
+ }
1542
+ function shortcutKey(event) {
1543
+ return event.code || event.key;
1544
+ }
1000
1545
  /**
1001
1546
  * Customizable piano component.
1002
1547
  */
1003
- const Piano = { Root: (0, react.forwardRef)(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, fill = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height = fill ? "100%" : 160, style, className, label, keyProps, onPlayNote, onStopNote, ...props }, forwardedRef) {
1548
+ const Piano = { Root: /* @__PURE__ */ (0, react.forwardRef)(function Root({ noteRange, glissando = true, midiMax = 127, keyboardShortcuts, keyboardShortcutsScope = "root", resizable = false, whiteKeyWidth = 40, keyGap = 1, blackKeyWidthRatio = .65, blackKeyHeightRatio = .6, height, style, className, label, keyProps, classes, onPlayNote, onStopNote, ...props }, forwardedRef) {
1004
1549
  const [node, setNode] = (0, react.useState)(null);
1005
1550
  const [activeNotes, setActiveNotes] = (0, react.useState)([]);
1006
- /** Set while `fill` is on, measured from the parent. */
1007
- const [filledKeyWidth, setFilledKeyWidth] = (0, react.useState)(whiteKeyWidth);
1008
- const notes = (0, react.useMemo)(() => (0, _tremolo_ui_functions.getNoteRangeArray)(noteRange), [noteRange]);
1551
+ /** Set while `resizable` is on, measured from the parent. */
1552
+ const [resizedKeyWidth, setResizedKeyWidth] = (0, react.useState)(whiteKeyWidth);
1553
+ const notes = (0, react.useMemo)(() => (0, _tremolo_ui_dom.getNoteRangeArray)(noteRange), [noteRange]);
1009
1554
  const whiteKeyCount = (0, react.useMemo)(() => notes.filter(_tremolo_ui_functions.isWhiteKey).length, [notes]);
1010
1555
  const layout = (0, react.useMemo)(() => ({
1011
1556
  noteRange,
1012
- whiteKeyWidth: fill ? filledKeyWidth : whiteKeyWidth,
1557
+ whiteKeyWidth: resizable ? resizedKeyWidth : whiteKeyWidth,
1013
1558
  keyGap,
1014
1559
  blackKeyWidthRatio,
1015
1560
  blackKeyHeightRatio
1016
1561
  }), [
1017
1562
  noteRange,
1018
- fill,
1019
- filledKeyWidth,
1563
+ resizable,
1564
+ resizedKeyWidth,
1020
1565
  whiteKeyWidth,
1021
1566
  keyGap,
1022
1567
  blackKeyWidthRatio,
@@ -1030,6 +1575,11 @@ const Piano = { Root: (0, react.forwardRef)(function Root({ noteRange, glissando
1030
1575
  onStopNote
1031
1576
  });
1032
1577
  const instanceRef = (0, react.useRef)(null);
1578
+ const shortcutNotes = (0, react.useRef)(/* @__PURE__ */ new Map());
1579
+ const releaseShortcutNotes = (0, react.useCallback)(() => {
1580
+ for (const { note, source } of shortcutNotes.current.values()) instanceRef.current?.noteOff(note, { source });
1581
+ shortcutNotes.current.clear();
1582
+ }, []);
1033
1583
  (0, react.useEffect)(() => {
1034
1584
  if (!node) return;
1035
1585
  const instance = (0, _tremolo_ui_dom.createPianoInput)(node, {
@@ -1061,35 +1611,76 @@ const Piano = { Root: (0, react.forwardRef)(function Root({ noteRange, glissando
1061
1611
  });
1062
1612
  });
1063
1613
  (0, react.useEffect)(() => {
1064
- if (!fill || !node) return;
1614
+ if (!resizable || !node) return;
1065
1615
  const parent = node.parentElement;
1066
1616
  if (!parent) throw new Error("doesn't have a parent element.");
1067
1617
  const resizeObserver = new ResizeObserver(() => {
1068
- setFilledKeyWidth(node.clientWidth / whiteKeyCount - keyGap);
1618
+ setResizedKeyWidth(node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap);
1069
1619
  });
1070
1620
  resizeObserver.observe(parent);
1071
1621
  return () => resizeObserver.disconnect();
1072
1622
  }, [
1073
- fill,
1623
+ resizable,
1074
1624
  node,
1075
1625
  whiteKeyCount,
1076
1626
  keyGap
1077
1627
  ]);
1628
+ const shortcutKeys = keyboardShortcuts?.keys;
1629
+ const hasShortcuts = shortcutKeys !== void 0;
1630
+ const shortcutMapping = shortcutKeys?.join("\0");
1631
+ (0, react.useEffect)(() => releaseShortcutNotes, [
1632
+ releaseShortcutNotes,
1633
+ shortcutMapping,
1634
+ keyboardShortcutsScope,
1635
+ noteRange.first,
1636
+ noteRange.last
1637
+ ]);
1078
1638
  /** The note a shortcut key plays, or null when it has none. */
1079
1639
  function shortcutNote(key) {
1080
- if (!keyboardShortcuts || key === "") return null;
1081
- const index = keyboardShortcuts.keys.indexOf(key);
1082
- return index === -1 ? null : noteRange.first + index;
1640
+ if (!shortcutKeys || key === "") return null;
1641
+ const index = shortcutKeys.indexOf(key);
1642
+ const note = noteRange.first + index;
1643
+ return index === -1 || note > noteRange.last ? null : note;
1083
1644
  }
1084
- useEventListener(globalThis.window, "keydown", (e) => {
1085
- if (e.repeat) return;
1645
+ const shortcutTarget = (0, react.useMemo)(() => () => {
1646
+ if (!hasShortcuts) return null;
1647
+ return keyboardShortcutsScope === "window" ? globalThis.window : node;
1648
+ }, [
1649
+ hasShortcuts,
1650
+ keyboardShortcutsScope,
1651
+ node
1652
+ ]);
1653
+ const focusOutTarget = (0, react.useMemo)(() => () => hasShortcuts && keyboardShortcutsScope === "root" ? node : null, [
1654
+ hasShortcuts,
1655
+ keyboardShortcutsScope,
1656
+ node
1657
+ ]);
1658
+ const windowBlurTarget = (0, react.useMemo)(() => () => hasShortcuts ? globalThis.window : null, [hasShortcuts]);
1659
+ useEventListener(shortcutTarget, "keydown", (e) => {
1660
+ const key = shortcutKey(e);
1661
+ if (e.repeat || shortcutNotes.current.has(key)) return;
1662
+ if (isEditableTarget(e.target)) return;
1086
1663
  const note = shortcutNote(e.key);
1087
- if (note !== null) instanceRef.current?.noteOn(note, { source: "keyboard" });
1664
+ if (note === null) return;
1665
+ const source = `keyboard:${key}`;
1666
+ shortcutNotes.current.set(key, {
1667
+ note,
1668
+ source
1669
+ });
1670
+ instanceRef.current?.noteOn(note, { source });
1088
1671
  });
1089
- useEventListener(globalThis.window, "keyup", (e) => {
1090
- const note = shortcutNote(e.key);
1091
- if (note !== null) instanceRef.current?.noteOff(note, { source: "keyboard" });
1672
+ useEventListener(shortcutTarget, "keyup", (e) => {
1673
+ const key = shortcutKey(e);
1674
+ const shortcut = shortcutNotes.current.get(key);
1675
+ if (!shortcut) return;
1676
+ shortcutNotes.current.delete(key);
1677
+ instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source });
1678
+ });
1679
+ useEventListener(focusOutTarget, "focusout", (e) => {
1680
+ if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget)) return;
1681
+ releaseShortcutNotes();
1092
1682
  });
1683
+ useEventListener(windowBlurTarget, "blur", releaseShortcutNotes);
1093
1684
  (0, react.useImperativeHandle)(forwardedRef, () => ({
1094
1685
  playNote: (note, velocity) => instanceRef.current?.noteOn(note, {
1095
1686
  source: "api",
@@ -1099,10 +1690,14 @@ const Piano = { Root: (0, react.forwardRef)(function Root({ noteRange, glissando
1099
1690
  }), []);
1100
1691
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1101
1692
  ref: setNode,
1102
- className: (0, clsx.default)("tremolo-piano", className),
1693
+ className,
1694
+ "data-resizable": resizable ? "" : void 0,
1695
+ role: "group",
1696
+ tabIndex: 0,
1103
1697
  style: {
1104
- width: fill ? "100%" : (0, _tremolo_ui_functions.pianoWidth)(layout),
1105
- height,
1698
+ width: resizable ? "100%" : (0, _tremolo_ui_dom.pianoWidth)(layout),
1699
+ "--height": cssLength(height),
1700
+ position: "relative",
1106
1701
  ...style
1107
1702
  },
1108
1703
  ...props,
@@ -1117,22 +1712,24 @@ const Piano = { Root: (0, react.forwardRef)(function Root({ noteRange, glissando
1117
1712
  const { className: keyClassName, style: keyStyle, ...rest } = keyProps?.(note, state) ?? {};
1118
1713
  const content = label?.(note, state);
1119
1714
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1120
- className: (0, clsx.default)(`tremolo-piano-${keyType}-key`, keyClassName),
1715
+ className: keyClassName,
1121
1716
  "data-note": note,
1122
1717
  "data-note-key": (0, _tremolo_ui_functions.noteKey)(note),
1123
- "data-active": state.active,
1124
- "aria-disabled": state.disabled,
1718
+ "data-active": state.active ? "" : void 0,
1719
+ "data-disabled": state.disabled ? "" : void 0,
1125
1720
  ...rest,
1126
1721
  style: {
1722
+ position: "absolute",
1723
+ zIndex: keyType === "white" ? 1 : 2,
1127
1724
  ...keyStyle,
1128
- left: (0, _tremolo_ui_functions.notePosition)(note, layout),
1129
- width: keyType === "white" ? layout.whiteKeyWidth : (0, _tremolo_ui_functions.blackKeyWidth)(layout),
1725
+ left: (0, _tremolo_ui_dom.notePosition)(note, layout),
1726
+ width: keyType === "white" ? layout.whiteKeyWidth : (0, _tremolo_ui_dom.blackKeyWidth)(layout),
1130
1727
  height: keyType === "white" ? "100%" : `${blackKeyHeightRatio * 100}%`
1131
1728
  },
1132
1729
  children: content !== "" && content !== null && content !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1133
- className: "tremolo-piano-key-label-wrapper",
1730
+ className: classes?.keyLabelWrapper,
1134
1731
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1135
- className: "tremolo-piano-key-label",
1732
+ className: classes?.keyLabel,
1136
1733
  children: content
1137
1734
  })
1138
1735
  })
@@ -1142,16 +1739,22 @@ const Piano = { Root: (0, react.forwardRef)(function Root({ noteRange, glissando
1142
1739
  }) };
1143
1740
  //#endregion
1144
1741
  //#region src/components/PointsEditor/Background.tsx
1145
- function Background({ className, children, ...props }) {
1742
+ function Background({ className, children, style, ...props }) {
1146
1743
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1147
- className: (0, clsx.default)("tremolo-points-editor-background", className),
1744
+ className,
1745
+ style: {
1746
+ position: "absolute",
1747
+ inset: 0,
1748
+ zIndex: 0,
1749
+ ...style
1750
+ },
1148
1751
  ...props,
1149
1752
  children
1150
1753
  });
1151
1754
  }
1152
1755
  //#endregion
1153
1756
  //#region src/components/PointsEditor/context.tsx
1154
- const PointsEditorContext = (0, react.createContext)(null);
1757
+ const PointsEditorContext = /* @__PURE__ */ (0, react.createContext)(null);
1155
1758
  const PointsEditorProvider = PointsEditorContext.Provider;
1156
1759
  function usePointsEditorContext(selector) {
1157
1760
  const context = (0, react.useContext)(PointsEditorContext);
@@ -1159,15 +1762,13 @@ function usePointsEditorContext(selector) {
1159
1762
  return selector ? selector(context) : context;
1160
1763
  }
1161
1764
  //#endregion
1162
- //#region src/components/PointsEditor/Container.tsx
1163
- function Container({ children, className, ref, ...props }) {
1164
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1165
- ref: useComposedRefs(ref, usePointsEditorContext((s) => s.containerRef)),
1166
- className: (0, clsx.default)("tremolo-points-editor-container", className),
1167
- ...props,
1168
- children
1169
- });
1170
- }
1765
+ //#region src/components/_util/VisuallyHiddenRangeInput.tsx
1766
+ const VisuallyHiddenRangeInput = /* @__PURE__ */ (0, react.forwardRef)((props, ref) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1767
+ ...props,
1768
+ ref,
1769
+ type: "range",
1770
+ style: visuallyHiddenStyle
1771
+ }));
1171
1772
  //#endregion
1172
1773
  //#region src/components/PointsEditor/Point.tsx
1173
1774
  function clampPoint(point, min, max) {
@@ -1185,138 +1786,463 @@ const AXIS = {
1185
1786
  min: 0,
1186
1787
  max: 1
1187
1788
  };
1188
- function Point({ value, min, max, size, width = 16, height = 16, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, ...props }) {
1189
- const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard } = usePointsEditorContext();
1789
+ /**
1790
+ * One setting for both axes, or one per axis. The point holds a range input
1791
+ * for each, so anything that names or describes it comes in a pair.
1792
+ */
1793
+ function perAxis(value, axis) {
1794
+ return typeof value === "string" ? value : value?.[axis];
1795
+ }
1796
+ function Point({ value, children, id: idProp, min, max, size, width, height, color, disabled: _disabled, readonly: _readonly, wheel: _wheel, keyboard: _keyboard, "aria-label": ariaLabel, "aria-valuetext": ariaValuetext, onChange, onDragStart, onDragEnd, className, style, onPointerDown, onKeyDown, onFocus, ...props }) {
1797
+ const { containerRef, externalStyles, disabled: rootDisabled, readonly: rootReadonly, wheel: rootWheel, keyboard: rootKeyboard, dragSensitivity, selection, registerPoint, beginPointDrag, movePointDrag, nudgeSelection } = usePointsEditorContext();
1798
+ const generatedId = (0, react.useId)();
1799
+ const id = idProp ?? generatedId;
1800
+ const selected = selection.includes(id);
1190
1801
  const disabled = _disabled ?? rootDisabled;
1191
1802
  const readonly = _readonly ?? rootReadonly;
1803
+ const inactive = disabled || readonly;
1192
1804
  const wheel = _wheel === void 0 ? rootWheel : _wheel;
1193
1805
  const keyboard = _keyboard === void 0 ? rootKeyboard : _keyboard;
1806
+ useCheckPlacement("PointsEditor.Point", "PointsEditor.Container");
1194
1807
  const [element, setElement] = (0, react.useState)(null);
1808
+ const xInputRef = (0, react.useRef)(null);
1809
+ const yInputRef = (0, react.useRef)(null);
1810
+ const registration = (0, react.useRef)({
1811
+ value,
1812
+ min,
1813
+ max,
1814
+ readonly: inactive,
1815
+ onChange,
1816
+ element,
1817
+ wheel
1818
+ });
1819
+ (0, react.useEffect)(() => {
1820
+ registration.current = {
1821
+ value,
1822
+ min,
1823
+ max,
1824
+ readonly: inactive,
1825
+ onChange,
1826
+ element,
1827
+ wheel
1828
+ };
1829
+ });
1830
+ (0, react.useEffect)(() => registerPoint(id, registration), [id, registerPoint]);
1831
+ /** Where the pointer was when the drag started, to measure the move from. */
1832
+ const pointerOrigin = (0, react.useRef)(null);
1195
1833
  const { refCallback: dragRefCallback, dragging } = useDragValue({
1196
1834
  axis: AXIS,
1197
1835
  baseElementRef: containerRef,
1198
- cursor: readonly ? void 0 : externalStyles.cursor,
1836
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
1837
+ cursor: inactive ? void 0 : externalStyles.cursor,
1838
+ shouldStart: () => !disabled,
1199
1839
  onChange: ([x, y]) => {
1200
- if (readonly) return;
1201
- onChange?.(clampPoint({
1202
- x,
1203
- y
1204
- }, min, max));
1840
+ const origin = pointerOrigin.current;
1841
+ if (!origin) return;
1842
+ movePointDrag({
1843
+ x: x - origin.x,
1844
+ y: y - origin.y
1845
+ });
1205
1846
  },
1206
- onDragStart: ([x, y]) => {
1207
- if (readonly) return;
1208
- if (externalStyles.userSelectNone) addUserSelectNone();
1209
- onDragStart?.(clampPoint({
1847
+ onDragStart: ([x, y], state) => {
1848
+ beginPointDrag(id, state.event);
1849
+ pointerOrigin.current = {
1210
1850
  x,
1211
1851
  y
1212
- }, min, max));
1852
+ };
1853
+ xInputRef.current?.focus();
1854
+ if (inactive) return;
1855
+ onDragStart?.(clampPoint(value, min, max));
1213
1856
  },
1214
- onDragEnd: ([x, y]) => {
1215
- if (readonly) return;
1216
- if (externalStyles.userSelectNone) removeUserSelectNone();
1217
- onDragEnd?.(clampPoint({
1218
- x,
1219
- y
1220
- }, min, max));
1857
+ onDragEnd: () => {
1858
+ pointerOrigin.current = null;
1859
+ if (inactive) return;
1860
+ onDragEnd?.(clampPoint(value, min, max));
1221
1861
  }
1222
1862
  });
1223
- const nudge = (0, react.useCallback)((axis, direction, option) => {
1224
- const next = (0, _tremolo_ui_functions.applyDelta)(value[axis], direction, option, AXIS);
1225
- onChange?.(clampPoint({
1226
- ...value,
1227
- [axis]: next
1228
- }, min, max));
1863
+ const nudge = (0, react.useCallback)((axis, direction, option, modifiers) => {
1864
+ const next = (0, _tremolo_ui_dom.applyDelta)(value[axis], direction, option, AXIS, modifiers);
1865
+ nudgeSelection(id, {
1866
+ x: axis === "x" ? next - value.x : 0,
1867
+ y: axis === "y" ? next - value.y : 0
1868
+ });
1229
1869
  }, [
1230
1870
  value,
1231
- min,
1232
- max,
1233
- onChange
1871
+ id,
1872
+ nudgeSelection
1234
1873
  ]);
1235
- useWheel((event) => {
1236
- if (!onChange || readonly || !wheel) return;
1237
- if (!element || element.ownerDocument.activeElement !== element) return;
1238
- event.preventDefault();
1239
- nudge(event.shiftKey ? "x" : "y", event.deltaY < 0 ? -1 : 1, wheel);
1240
- }, { target: containerRef });
1241
- const refCallback = useComposedRefs(dragRefCallback, setElement);
1874
+ const refCallback = require_compose_refs.useComposedRefs(dragRefCallback, setElement);
1242
1875
  const handleKeyDown = (event) => {
1243
- if (!onChange || readonly || !keyboard) return;
1244
1876
  const key = event.key;
1245
- if ([
1877
+ if (![
1246
1878
  "ArrowRight",
1247
1879
  "ArrowLeft",
1248
1880
  "ArrowUp",
1249
1881
  "ArrowDown"
1250
- ].includes(key)) {
1251
- event.preventDefault();
1252
- nudge(key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y", key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard);
1253
- }
1882
+ ].includes(key)) return;
1883
+ const axis = key === "ArrowRight" || key === "ArrowLeft" ? "x" : "y";
1884
+ event.preventDefault();
1885
+ if (!onChange || inactive || !keyboard) return;
1886
+ nudge(axis, key === "ArrowLeft" || key === "ArrowUp" ? -1 : 1, keyboard, event);
1254
1887
  };
1255
- const colors = { "--color": color };
1256
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1888
+ const current = clampPoint(value, min, max);
1889
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1257
1890
  ref: refCallback,
1258
- className: (0, clsx.default)("tremolo-points-editor-point", className),
1259
- tabIndex: 0,
1260
- "aria-disabled": disabled,
1261
- "aria-readonly": readonly,
1262
- "data-dragging": dragging,
1891
+ className,
1892
+ "data-disabled": disabled ? "" : void 0,
1893
+ "data-readonly": readonly ? "" : void 0,
1894
+ tabIndex: -1,
1895
+ "data-dragging": dragging ? "" : void 0,
1896
+ "data-selected": selected ? "" : void 0,
1263
1897
  style: {
1264
- ...colors,
1265
- width: size ?? width,
1266
- height: size ?? height,
1898
+ "--color": color,
1899
+ "--width": cssLength(size ?? width),
1900
+ "--height": cssLength(size ?? height),
1901
+ position: "absolute",
1902
+ translate: "var(--translate, -50% -50%)",
1903
+ ...style,
1267
1904
  left: `${value.x * 100}%`,
1268
- top: `${value.y * 100}%`,
1269
- ...style
1905
+ top: `${value.y * 100}%`
1270
1906
  },
1271
1907
  onPointerDown,
1908
+ onFocus: (event) => {
1909
+ if (!disabled && event.target === event.currentTarget) xInputRef.current?.focus();
1910
+ onFocus?.(event);
1911
+ },
1272
1912
  onKeyDown: (event) => {
1273
1913
  handleKeyDown(event);
1274
1914
  onKeyDown?.(event);
1275
1915
  },
1276
- ...props
1916
+ ...props,
1917
+ children: [["x", "y"].map((axis) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VisuallyHiddenRangeInput, {
1918
+ ref: axis === "x" ? xInputRef : yInputRef,
1919
+ "data-axis": axis,
1920
+ value: current[axis],
1921
+ min: min?.[axis] ?? 0,
1922
+ max: max?.[axis] ?? 1,
1923
+ step: "any",
1924
+ disabled,
1925
+ "aria-readonly": readonly,
1926
+ "aria-orientation": axis === "x" ? "horizontal" : "vertical",
1927
+ "aria-label": perAxis(ariaLabel, axis) ?? axis,
1928
+ "aria-valuetext": perAxis(ariaValuetext, axis),
1929
+ onChange: (event) => {
1930
+ if (readonly) {
1931
+ event.currentTarget.value = String(current[axis]);
1932
+ return;
1933
+ }
1934
+ const delta = event.currentTarget.valueAsNumber - value[axis];
1935
+ nudgeSelection(id, {
1936
+ x: axis === "x" ? delta : 0,
1937
+ y: axis === "y" ? delta : 0
1938
+ });
1939
+ }
1940
+ }, axis)), children]
1941
+ });
1942
+ }
1943
+ //#endregion
1944
+ //#region src/components/PointsEditor/Container.tsx
1945
+ const Container = /* @__PURE__ */ (0, react.forwardRef)(function Container({ children, className, style, ...props }, forwardedRef) {
1946
+ const { containerRef, disabled, selectable, isPointElement, nudgeFocusedPoint, beginSelectionBox, moveSelectionBox, endSelectionBox } = usePointsEditorContext();
1947
+ const { refCallback: dragRefCallback } = useDragValue({
1948
+ axis: AXIS,
1949
+ baseElementRef: containerRef,
1950
+ shouldStart: (event) => !disabled && !isPointElement(event.target),
1951
+ onDragStart: ([x, y], state) => {
1952
+ beginSelectionBox({
1953
+ x,
1954
+ y
1955
+ }, state.event);
1956
+ },
1957
+ onChange: ([x, y]) => moveSelectionBox({
1958
+ x,
1959
+ y
1960
+ }),
1961
+ onDragEnd: endSelectionBox
1962
+ });
1963
+ useWheel((event) => {
1964
+ const horizontal = event.deltaX !== 0;
1965
+ const delta = horizontal ? event.deltaX : event.deltaY;
1966
+ if (delta === 0) return;
1967
+ const axis = horizontal || event.shiftKey ? "x" : "y";
1968
+ if (nudgeFocusedPoint(axis, delta < 0 ? -1 : 1, event)) event.preventDefault();
1969
+ }, { target: containerRef });
1970
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, containerRef, selectable && !disabled ? dragRefCallback : void 0);
1971
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1972
+ ref: composedRef,
1973
+ className,
1974
+ style: {
1975
+ position: "absolute",
1976
+ inset: 0,
1977
+ zIndex: 10,
1978
+ ...style
1979
+ },
1980
+ ...props,
1981
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
1982
+ name: "PointsEditor.Container",
1983
+ children
1984
+ })
1985
+ });
1986
+ });
1987
+ //#endregion
1988
+ //#region src/components/PointsEditor/SelectionBox.tsx
1989
+ /**
1990
+ * The box a drag on empty space draws, and what it covers is selected.
1991
+ *
1992
+ * Nothing is rendered while no drag is running, and leaving it out leaves the
1993
+ * editor without one — the selection still works, it just cannot be seen.
1994
+ */
1995
+ function SelectionBox({ children, className, style, ...props }) {
1996
+ useCheckPlacement("PointsEditor.SelectionBox", "PointsEditor.Container");
1997
+ const selectionBox = usePointsEditorContext((s) => s.selectionBox);
1998
+ if (!selectionBox) return null;
1999
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2000
+ className,
2001
+ ...props,
2002
+ style: {
2003
+ position: "absolute",
2004
+ zIndex: 20,
2005
+ pointerEvents: "none",
2006
+ ...style,
2007
+ left: `${selectionBox.x * 100}%`,
2008
+ top: `${selectionBox.y * 100}%`,
2009
+ width: `${selectionBox.width * 100}%`,
2010
+ height: `${selectionBox.height * 100}%`
2011
+ },
2012
+ children
1277
2013
  });
1278
2014
  }
1279
2015
  //#endregion
1280
2016
  //#region src/components/PointsEditor/index.tsx
2017
+ /** One array for every editor with selection turned off, so memos hold still. */
2018
+ const EMPTY = [];
2019
+ /**
2020
+ * How far a point may move before something in the selection leaves its range.
2021
+ *
2022
+ * Clamping each point on its own would break the shape of the selection: the
2023
+ * one that reached the edge would stop while the rest carried on. One amount
2024
+ * for all of them means the whole selection stops together.
2025
+ */
2026
+ function allowedDelta(delta, entries) {
2027
+ let loX = -Infinity;
2028
+ let hiX = Infinity;
2029
+ let loY = -Infinity;
2030
+ let hiY = Infinity;
2031
+ for (const { start, registration } of entries) {
2032
+ loX = Math.max(loX, (registration.min?.x ?? 0) - start.x);
2033
+ hiX = Math.min(hiX, (registration.max?.x ?? 1) - start.x);
2034
+ loY = Math.max(loY, (registration.min?.y ?? 0) - start.y);
2035
+ hiY = Math.min(hiY, (registration.max?.y ?? 1) - start.y);
2036
+ }
2037
+ return {
2038
+ x: hiX < loX ? 0 : (0, _tremolo_ui_functions.clamp)(delta.x, loX, hiX),
2039
+ y: hiY < loY ? 0 : (0, _tremolo_ui_functions.clamp)(delta.y, loY, hiY)
2040
+ };
2041
+ }
1281
2042
  /**
1282
2043
  * A point moves over 0..1 in both axes, so a nudge of 0.01 crosses the editor
1283
2044
  * in a hundred steps whatever its pixel size.
1284
2045
  */
1285
- const DEFAULT_INPUT_EVENT_OPTION = ["normalized", .01];
2046
+ const DEFAULT_WHEEL = ["normalized", .01];
2047
+ /**
2048
+ * Shift is the fine-adjustment key everywhere else, so it is bound here too
2049
+ * — but only on the keyboard. On the wheel it already means the x axis, and
2050
+ * browsers hand shift+wheel over as horizontal scrolling anyway.
2051
+ */
2052
+ const DEFAULT_KEYBOARD = {
2053
+ default: ["normalized", .01],
2054
+ shift: ["normalized", .001]
2055
+ };
1286
2056
  /**
1287
2057
  * Multiple Point Controller
1288
2058
  */
1289
2059
  const PointsEditor = {
1290
- Root: (0, react.forwardRef)(({ width = 200, height = 100, disabled = false, readonly = false, wheel = DEFAULT_INPUT_EVENT_OPTION, keyboard = DEFAULT_INPUT_EVENT_OPTION, externalStyles, style, className, children, ...props }, forwardedRef) => {
2060
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ width, height, disabled = false, readonly = false, wheel = DEFAULT_WHEEL, keyboard = DEFAULT_KEYBOARD, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, selectable = false, selection: selectionProp, defaultSelection, onSelectionChange, externalStyles, style, className, children, ...props }, forwardedRef) => {
1291
2061
  const containerRef = (0, react.useRef)(null);
1292
- const { userSelectNone = true, cursor = "grabbing" } = externalStyles ?? {};
2062
+ const { cursor = "grabbing" } = externalStyles ?? {};
2063
+ const controlled = selectionProp !== void 0;
2064
+ const [ownSelection, setOwnSelection] = (0, react.useState)(defaultSelection ?? []);
2065
+ const selection = selectable ? selectionProp ?? ownSelection : EMPTY;
2066
+ const selectionRef = (0, react.useRef)(selection);
2067
+ const changeHandlerRef = (0, react.useRef)(onSelectionChange);
2068
+ (0, react.useEffect)(() => {
2069
+ selectionRef.current = selection;
2070
+ changeHandlerRef.current = onSelectionChange;
2071
+ });
2072
+ const changeSelection = (0, react.useCallback)((next) => {
2073
+ selectionRef.current = next;
2074
+ if (!controlled) setOwnSelection(next);
2075
+ changeHandlerRef.current?.(next);
2076
+ }, [controlled]);
2077
+ /**
2078
+ * Every mounted point, by id. A registration is a ref rather than a value:
2079
+ * the value inside changes on every frame of a drag, and a registry keyed
2080
+ * on it would be rebuilt just as often.
2081
+ */
2082
+ const points = (0, react.useRef)(/* @__PURE__ */ new Map());
2083
+ const registerPoint = (0, react.useCallback)((id, entry) => {
2084
+ points.current.set(id, entry);
2085
+ return () => {
2086
+ points.current.delete(id);
2087
+ };
2088
+ }, []);
2089
+ /** What the current drag picked up, and where those points started. */
2090
+ const dragRef = (0, react.useRef)([]);
2091
+ const snapshot = (0, react.useCallback)((ids) => {
2092
+ return ids.flatMap((id) => {
2093
+ const registration = points.current.get(id)?.current;
2094
+ return registration ? [{
2095
+ id,
2096
+ start: { ...registration.value }
2097
+ }] : [];
2098
+ });
2099
+ }, []);
2100
+ const applyDeltaTo = (0, react.useCallback)((entries, delta) => {
2101
+ const withRegistration = entries.flatMap((entry) => {
2102
+ const registration = points.current.get(entry.id)?.current;
2103
+ return registration ? [{
2104
+ ...entry,
2105
+ registration
2106
+ }] : [];
2107
+ });
2108
+ const allowed = allowedDelta(delta, withRegistration);
2109
+ for (const { start, registration } of withRegistration) {
2110
+ if (registration.readonly) continue;
2111
+ registration.onChange?.({
2112
+ x: (0, _tremolo_ui_functions.toPrecision)(start.x + allowed.x),
2113
+ y: (0, _tremolo_ui_functions.toPrecision)(start.y + allowed.y)
2114
+ });
2115
+ }
2116
+ }, []);
2117
+ const beginPointDrag = (0, react.useCallback)((id, modifiers) => {
2118
+ if (!selectable) {
2119
+ dragRef.current = snapshot([id]);
2120
+ return;
2121
+ }
2122
+ const current = selectionRef.current;
2123
+ const additive = modifiers.ctrlKey || modifiers.metaKey;
2124
+ let next;
2125
+ if (additive) next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id];
2126
+ else if (current.includes(id)) next = current;
2127
+ else next = [id];
2128
+ changeSelection([...next]);
2129
+ dragRef.current = next.includes(id) ? snapshot(next) : [];
2130
+ }, [
2131
+ selectable,
2132
+ changeSelection,
2133
+ snapshot
2134
+ ]);
2135
+ const movePointDrag = (0, react.useCallback)((delta) => applyDeltaTo(dragRef.current, delta), [applyDeltaTo]);
2136
+ const nudgeSelection = (0, react.useCallback)((id, delta) => {
2137
+ const ids = selectionRef.current.includes(id) ? selectionRef.current : [id];
2138
+ applyDeltaTo(snapshot(ids), delta);
2139
+ }, [applyDeltaTo, snapshot]);
2140
+ const isPointElement = (0, react.useCallback)((element) => {
2141
+ if (!element) return false;
2142
+ for (const [, entry] of points.current) if (entry.current.element?.contains(element)) return true;
2143
+ return false;
2144
+ }, []);
2145
+ const nudgeFocusedPoint = (0, react.useCallback)((axis, direction, modifiers) => {
2146
+ const container = containerRef.current;
2147
+ const active = container?.ownerDocument.activeElement;
2148
+ if (!container || !active || !container.contains(active)) return false;
2149
+ for (const [id, entry] of points.current) {
2150
+ const { element, wheel, readonly, onChange, value } = entry.current;
2151
+ if (!element?.contains(active)) continue;
2152
+ if (!wheel || readonly || !onChange) return false;
2153
+ const next = (0, _tremolo_ui_dom.applyDelta)(value[axis], direction, wheel, AXIS, modifiers);
2154
+ nudgeSelection(id, {
2155
+ x: axis === "x" ? next - value.x : 0,
2156
+ y: axis === "y" ? next - value.y : 0
2157
+ });
2158
+ return true;
2159
+ }
2160
+ return false;
2161
+ }, [nudgeSelection]);
2162
+ const [selectionBox, setSelectionBox] = (0, react.useState)(null);
2163
+ const selectionBoxRef = (0, react.useRef)(null);
2164
+ selectionBoxRef.current ??= (0, _tremolo_ui_dom.createSelectionBox)({
2165
+ *items() {
2166
+ for (const [id, entry] of points.current) {
2167
+ const { x, y } = entry.current.value;
2168
+ yield [id, [x, y]];
2169
+ }
2170
+ },
2171
+ onBoxChange: setSelectionBox
2172
+ });
2173
+ (0, react.useEffect)(() => {
2174
+ selectionBoxRef.current?.update({ onSelectionChange: changeSelection });
2175
+ }, [changeSelection]);
2176
+ (0, react.useEffect)(() => {
2177
+ const box = selectionBoxRef.current;
2178
+ return () => box?.destroy();
2179
+ }, []);
2180
+ const beginSelectionBox = (0, react.useCallback)((at, modifiers) => {
2181
+ if (!selectable) return;
2182
+ selectionBoxRef.current?.begin([at.x, at.y], {
2183
+ additive: modifiers.ctrlKey || modifiers.metaKey,
2184
+ selection: selectionRef.current
2185
+ });
2186
+ }, [selectable]);
2187
+ const moveSelectionBox = (0, react.useCallback)((to) => {
2188
+ selectionBoxRef.current?.move([to.x, to.y]);
2189
+ }, []);
2190
+ const endSelectionBox = (0, react.useCallback)(() => {
2191
+ if (!selectionBoxRef.current?.end()) return;
2192
+ const [first] = selectionRef.current;
2193
+ if (first) points.current.get(first)?.current.element?.focus();
2194
+ }, []);
2195
+ const context = (0, react.useMemo)(() => ({
2196
+ disabled,
2197
+ readonly,
2198
+ wheel,
2199
+ keyboard,
2200
+ dragSensitivity,
2201
+ externalStyles: { cursor },
2202
+ containerRef,
2203
+ selectable,
2204
+ selection,
2205
+ registerPoint,
2206
+ isPointElement,
2207
+ beginPointDrag,
2208
+ movePointDrag,
2209
+ nudgeSelection,
2210
+ nudgeFocusedPoint,
2211
+ selectionBox,
2212
+ beginSelectionBox,
2213
+ moveSelectionBox,
2214
+ endSelectionBox
2215
+ }), [
2216
+ disabled,
2217
+ readonly,
2218
+ wheel,
2219
+ keyboard,
2220
+ dragSensitivity,
2221
+ cursor,
2222
+ selectable,
2223
+ selection,
2224
+ registerPoint,
2225
+ isPointElement,
2226
+ beginPointDrag,
2227
+ movePointDrag,
2228
+ nudgeSelection,
2229
+ nudgeFocusedPoint,
2230
+ selectionBox,
2231
+ beginSelectionBox,
2232
+ moveSelectionBox,
2233
+ endSelectionBox
2234
+ ]);
1293
2235
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointsEditorProvider, {
1294
- value: (0, react.useMemo)(() => ({
1295
- disabled,
1296
- readonly,
1297
- wheel,
1298
- keyboard,
1299
- externalStyles: {
1300
- userSelectNone,
1301
- cursor
1302
- },
1303
- containerRef
1304
- }), [
1305
- disabled,
1306
- readonly,
1307
- wheel,
1308
- keyboard,
1309
- userSelectNone,
1310
- cursor
1311
- ]),
2236
+ value: context,
1312
2237
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1313
2238
  ref: forwardedRef,
1314
- className: (0, clsx.default)("tremolo-points-editor", className),
1315
- "aria-disabled": disabled,
1316
- "aria-readonly": readonly,
2239
+ className,
2240
+ "data-disabled": disabled ? "" : void 0,
2241
+ "data-readonly": readonly ? "" : void 0,
1317
2242
  style: {
1318
- width,
1319
- height,
2243
+ "--width": cssLength(width),
2244
+ "--height": cssLength(height),
2245
+ position: "relative",
1320
2246
  ...style
1321
2247
  },
1322
2248
  ...props,
@@ -1326,11 +2252,12 @@ const PointsEditor = {
1326
2252
  }),
1327
2253
  Background,
1328
2254
  Container,
1329
- Point
2255
+ Point,
2256
+ SelectionBox
1330
2257
  };
1331
2258
  //#endregion
1332
2259
  //#region src/components/Slider/context.tsx
1333
- const SliderContext = (0, react.createContext)(null);
2260
+ const SliderContext = /* @__PURE__ */ (0, react.createContext)(null);
1334
2261
  const SliderProvider = SliderContext.Provider;
1335
2262
  function useSliderContext(selector) {
1336
2263
  const context = (0, react.useContext)(SliderContext);
@@ -1339,7 +2266,8 @@ function useSliderContext(selector) {
1339
2266
  }
1340
2267
  //#endregion
1341
2268
  //#region src/components/Slider/MarksOption.tsx
1342
- function MarksOption({ value, type = "mark-number", label, thickness = 1, length = "0.5rem", gap = 2, labelWidth, classes, styles, className, style, ...props }) {
2269
+ function MarksOption({ value, mark = true, label, thickness, length, gap, labelWidth, classes, styles, className, style, ...props }) {
2270
+ useCheckPlacement("Slider.MarksOption", "Slider.Marks");
1343
2271
  const min = useSliderContext((s) => s.min);
1344
2272
  const max = useSliderContext((s) => s.max);
1345
2273
  const scale = useSliderContext((s) => s.scale);
@@ -1347,7 +2275,7 @@ function MarksOption({ value, type = "mark-number", label, thickness = 1, length
1347
2275
  const reverse = useSliderContext((s) => s.reverse);
1348
2276
  const calcPercent = (0, react.useCallback)((value) => {
1349
2277
  const percent = scale.normalize(value, min, max) * 100;
1350
- return (0, _tremolo_ui_functions.toFixed)((0, _tremolo_ui_functions.xor)(vertical, reverse) ? 100 - percent : percent);
2278
+ return (0, _tremolo_ui_functions.toFixed)(vertical !== reverse ? 100 - percent : percent);
1351
2279
  }, [
1352
2280
  vertical,
1353
2281
  reverse,
@@ -1356,140 +2284,184 @@ function MarksOption({ value, type = "mark-number", label, thickness = 1, length
1356
2284
  scale
1357
2285
  ]);
1358
2286
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1359
- className: (0, clsx.default)("tremolo-slider-marks-option", className),
2287
+ className,
1360
2288
  style: {
2289
+ "--thickness": cssLength(thickness),
2290
+ "--length": cssLength(length),
2291
+ "--gap": cssLength(gap),
2292
+ "--label-width": cssLength(labelWidth),
2293
+ position: "absolute",
2294
+ translate: vertical ? "var(--translate, 0 -50%)" : "var(--translate, -50% 0)",
2295
+ zIndex: 10,
2296
+ ...style,
1361
2297
  left: !vertical ? `${calcPercent(value)}%` : void 0,
1362
- top: vertical ? `${calcPercent(value)}%` : void 0,
1363
- ...style
2298
+ top: vertical ? `${calcPercent(value)}%` : void 0
1364
2299
  },
1365
- "data-vertical": vertical,
2300
+ "data-orientation": vertical ? "vertical" : "horizontal",
1366
2301
  ...props,
1367
- children: [type != "number" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1368
- className: (0, clsx.default)("tremolo-slider-marks-option-mark", classes?.mark),
1369
- style: {
1370
- width: !vertical ? thickness : length,
1371
- height: vertical ? thickness : length,
1372
- marginBottom: !vertical ? gap : void 0,
1373
- marginRight: vertical ? gap : void 0,
1374
- ...styles?.mark
1375
- },
1376
- "data-vertical": vertical
1377
- }), type != "mark" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1378
- className: (0, clsx.default)("tremolo-slider-marks-option-label", classes?.label),
1379
- style: {
1380
- width: labelWidth,
1381
- ...styles?.label
1382
- },
1383
- children: label || value
2302
+ children: [mark && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2303
+ className: classes?.mark,
2304
+ style: styles?.mark,
2305
+ "data-orientation": vertical ? "vertical" : "horizontal"
2306
+ }), label !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2307
+ className: classes?.label,
2308
+ style: styles?.label,
2309
+ children: label ?? value
1384
2310
  })]
1385
2311
  });
1386
2312
  }
1387
2313
  //#endregion
2314
+ //#region src/components/_util/decimal-digits.ts
2315
+ /**
2316
+ * How many digits a number carries after the decimal point.
2317
+ *
2318
+ * `Slider.Marks` builds its labels by multiplying an interval, which leaves
2319
+ * binary debris in the last digits (`0.1 * 3` is `0.30000000000000004`). The
2320
+ * count is what tells `toFixed` how far to round that back.
2321
+ *
2322
+ * The exponent form has to be handled separately: `String(1e-7)` is `'1e-7'`,
2323
+ * which has no decimal point at all, so reading the text after the point
2324
+ * reports no digits and the interval rounds away to whole numbers.
2325
+ */
2326
+ function decimalDigits(x) {
2327
+ if (!Number.isFinite(x)) return 0;
2328
+ const text = String(x);
2329
+ const e = text.indexOf("e");
2330
+ if (e === -1) return text.split(".")[1]?.length ?? 0;
2331
+ const fraction = text.slice(0, e).split(".")[1]?.length ?? 0;
2332
+ return Math.max(0, fraction - Number(text.slice(e + 1)));
2333
+ }
2334
+ //#endregion
1388
2335
  //#region src/components/Slider/type.ts
1389
2336
  function generateOptionsList(options, min, max, step) {
2337
+ const { per, mark = true, label = true } = typeof options === "object" ? options : { per: options };
1390
2338
  const optionsList = [];
1391
- const per = options[0] == "step" ? step : options[0];
1392
- const count = Math.floor(max / per) - Math.ceil(min / per) + 1;
2339
+ const interval = per === "step" ? step : per;
2340
+ const count = Math.floor(max / interval) - Math.ceil(min / interval) + 1;
1393
2341
  for (let i = 0; i < count; i++) {
1394
- const value = (0, _tremolo_ui_functions.toFixed)(per * (Math.ceil(min / per) + i), (0, _tremolo_ui_functions.decimalPart)(per)?.length);
2342
+ const value = (0, _tremolo_ui_functions.toFixed)(interval * (Math.ceil(min / interval) + i), decimalDigits(interval));
1395
2343
  optionsList.push({
1396
2344
  value,
1397
- type: options[1]
2345
+ mark,
2346
+ label
1398
2347
  });
1399
2348
  }
1400
2349
  return optionsList;
1401
2350
  }
1402
2351
  //#endregion
1403
2352
  //#region src/components/Slider/Marks.tsx
1404
- function Marks({ gap = 6, options, children, className, style, ...props }) {
2353
+ function Marks({ gap, options, children, className, style, ...props }) {
1405
2354
  const min = useSliderContext((s) => s.min);
1406
2355
  const max = useSliderContext((s) => s.max);
1407
2356
  const step = useSliderContext((s) => s.step);
1408
2357
  const vertical = useSliderContext((s) => s.vertical);
1409
2358
  const reverse = useSliderContext((s) => s.reverse);
1410
2359
  const optionsList = options ? generateOptionsList(options, min, max, step) : [];
1411
- if ((0, _tremolo_ui_functions.xor)(vertical, reverse)) optionsList.reverse();
2360
+ if (vertical !== reverse) optionsList.reverse();
1412
2361
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1413
- className: (0, clsx.default)("tremolo-slider-marks", className),
2362
+ className,
2363
+ "data-orientation": vertical ? "vertical" : "horizontal",
1414
2364
  style: {
1415
- marginLeft: vertical ? gap : void 0,
1416
- marginTop: !vertical ? gap : void 0,
2365
+ "--gap": cssLength(gap),
2366
+ position: "relative",
1417
2367
  ...style
1418
2368
  },
1419
2369
  ...props,
1420
- children: options ? optionsList.map(({ value, type }, index) => {
1421
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarksOption, {
1422
- value,
1423
- type
1424
- }, index);
1425
- }) : children
2370
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
2371
+ name: "Slider.Marks",
2372
+ children: options ? optionsList.map(({ value, mark, label }, index) => {
2373
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarksOption, {
2374
+ value,
2375
+ mark,
2376
+ label: label ? void 0 : null
2377
+ }, index);
2378
+ }) : children
2379
+ })
1426
2380
  });
1427
2381
  }
1428
2382
  //#endregion
1429
2383
  //#region src/components/Slider/Thumb.tsx
1430
- function Thumb$1({ color, children, className, style, ref }) {
1431
- const elementRef = (0, react.useRef)(null);
1432
- const { vertical, disabled, readonly, percent, thumbRef } = useSliderContext();
2384
+ const Thumb$1 = /* @__PURE__ */ (0, react.forwardRef)(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, ...props }, forwardedRef) {
2385
+ const inputRef = (0, react.useRef)(null);
2386
+ const { value, min, max, step, vertical, disabled, readonly, onChange, percent, thumbRef } = useSliderContext();
2387
+ useCheckPlacement("Slider.Thumb", "Slider.Track");
1433
2388
  const methods = () => ({
1434
2389
  focus() {
1435
- elementRef.current?.focus();
2390
+ if (!disabled) inputRef.current?.focus();
1436
2391
  },
1437
2392
  blur() {
1438
- elementRef.current?.blur();
2393
+ inputRef.current?.blur();
1439
2394
  }
1440
2395
  });
1441
- (0, react.useImperativeHandle)(ref, methods, []);
1442
- (0, react.useImperativeHandle)(thumbRef, methods, []);
1443
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1444
- className: "tremolo-slider-thumb-wrapper",
2396
+ (0, react.useImperativeHandle)(forwardedRef, methods, [disabled]);
2397
+ (0, react.useImperativeHandle)(thumbRef, methods, [disabled]);
2398
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2399
+ className,
2400
+ "data-disabled": disabled ? "" : void 0,
2401
+ "data-readonly": readonly ? "" : void 0,
2402
+ ...props,
1445
2403
  style: {
2404
+ "--color": color,
2405
+ position: "absolute",
2406
+ translate: "var(--translate, -50% -50%)",
2407
+ zIndex: 100,
2408
+ ...style,
1446
2409
  top: vertical ? `${percent}%` : "50%",
1447
2410
  left: !vertical ? `${percent}%` : "50%"
1448
2411
  },
1449
- children: children ? children : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1450
- ref: elementRef,
1451
- className: (0, clsx.default)("tremolo-slider-thumb", className),
1452
- tabIndex: 0,
1453
- "aria-disabled": disabled,
2412
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(VisuallyHiddenRangeInput, {
2413
+ ref: inputRef,
2414
+ value,
2415
+ min,
2416
+ max,
2417
+ step,
2418
+ disabled,
1454
2419
  "aria-readonly": readonly,
1455
- style: {
1456
- "--color": color,
1457
- ...style
2420
+ "aria-orientation": vertical ? "vertical" : "horizontal",
2421
+ "aria-label": ariaLabel,
2422
+ "aria-labelledby": ariaLabelledby,
2423
+ "aria-describedby": ariaDescribedby,
2424
+ "aria-valuetext": ariaValuetext,
2425
+ onChange: (event) => {
2426
+ if (readonly) {
2427
+ event.currentTarget.value = String(value);
2428
+ return;
2429
+ }
2430
+ onChange?.(event.currentTarget.valueAsNumber);
1458
2431
  }
1459
- })
2432
+ }), children]
1460
2433
  });
1461
- }
1462
- function Track({ length = 140, thickness = 10, active, inactive, children, className, style, defaultStyle = true, ref, ...props }) {
2434
+ });
2435
+ //#endregion
2436
+ //#region src/components/Slider/Track.tsx
2437
+ const Track = /* @__PURE__ */ (0, react.forwardRef)(function Track({ length, thickness, active, inactive, children, className, style, ...props }, forwardedRef) {
1463
2438
  const { vertical, reverse, disabled, percent, trackRef } = useSliderContext();
1464
- const composedRef = useComposedRefs(ref, trackRef);
1465
- const direction = vertical ? "bottom" : "right";
1466
- const colors = {
1467
- "--active": active,
1468
- "--inactive": inactive
1469
- };
2439
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, trackRef);
1470
2440
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1471
2441
  ref: composedRef,
1472
- className: (0, clsx.default)("tremolo-slider-track", className),
1473
- "aria-disabled": disabled,
1474
- "data-vertical": vertical,
1475
- style: !defaultStyle ? style : {
1476
- ...colors,
1477
- background: (0, _tremolo_ui_functions.xor)(vertical, reverse) ? `linear-gradient(to ${direction}, var(--inactive) ${percent}%, var(--active) ${percent}%)` : `linear-gradient(to ${direction}, var(--active) ${percent}%, var(--inactive) ${percent}%)`,
1478
- borderRadius: (0, _tremolo_ui_functions.styleHelper)(thickness, "/", 2),
1479
- width: !vertical ? length : thickness,
1480
- height: vertical ? length : thickness,
2442
+ className,
2443
+ "data-disabled": disabled ? "" : void 0,
2444
+ "data-orientation": vertical ? "vertical" : "horizontal",
2445
+ "data-flipped": vertical !== reverse ? "" : void 0,
2446
+ style: {
2447
+ "--active": active,
2448
+ "--inactive": inactive,
2449
+ "--length": cssLength(length),
2450
+ "--thickness": cssLength(thickness),
2451
+ "--percent": `${percent}%`,
2452
+ position: "relative",
1481
2453
  ...style
1482
2454
  },
1483
2455
  ...props,
1484
- children
2456
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
2457
+ name: "Slider.Track",
2458
+ children
2459
+ })
1485
2460
  });
1486
- }
2461
+ });
1487
2462
  //#endregion
1488
2463
  //#region src/components/Slider/index.tsx
1489
- const defaultExternalStyles$1 = {
1490
- userSelectNone: true,
1491
- cursor: "pointer"
1492
- };
2464
+ const defaultExternalStyles$1 = { cursor: "pointer" };
1493
2465
  /**
1494
2466
  * The wheel only acts while the focus is inside, so that scrolling a page past
1495
2467
  * the control does not change its value.
@@ -1499,16 +2471,17 @@ const WHEEL_OPTIONS$1 = { requireFocus: true };
1499
2471
  * Customizable slider
1500
2472
  */
1501
2473
  const Slider = {
1502
- Root: (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = ["raw", 1], keyboard = ["raw", 1], disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
2474
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min, max, step = 1, scale = _tremolo_ui_functions.linearScale, vertical = false, reverse = false, externalStyles: _externalStyles, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, className, style, children, onFocus, onBlur, onPointerDown, onKeyDown, ...props }, forwardedRef) => {
1503
2475
  const trackRef = (0, react.useRef)(null);
1504
2476
  const thumbRef = (0, react.useRef)(null);
1505
2477
  const externalStyles = {
1506
2478
  ...defaultExternalStyles$1,
1507
2479
  ..._externalStyles
1508
2480
  };
2481
+ const inactive = disabled || readonly;
1509
2482
  const p = (0, _tremolo_ui_functions.toFixed)(scale.normalize(value, min, max) * 100);
1510
2483
  const rev = (0, _tremolo_ui_functions.toFixed)(100 - p);
1511
- const displayReversed = (0, react.useMemo)(() => (0, _tremolo_ui_functions.xor)(vertical, reverse), [vertical, reverse]);
2484
+ const displayReversed = vertical !== reverse;
1512
2485
  const percent = displayReversed ? rev : p;
1513
2486
  const axis = (0, react.useMemo)(() => ({
1514
2487
  min,
@@ -1523,24 +2496,29 @@ const Slider = {
1523
2496
  scale,
1524
2497
  displayReversed
1525
2498
  ]);
2499
+ useCheckSteps({
2500
+ component: "Slider",
2501
+ range: axis,
2502
+ keyboard,
2503
+ wheel
2504
+ });
1526
2505
  const handleKeyDown = (0, react.useCallback)((event) => {
1527
- if (!keyboard || !onChange || readonly) return;
1528
2506
  const key = event.key;
1529
- if ([
2507
+ if (![
1530
2508
  "ArrowRight",
1531
2509
  "ArrowLeft",
1532
2510
  "ArrowUp",
1533
2511
  "ArrowDown"
1534
- ].includes(key)) {
1535
- event.preventDefault();
1536
- let direction = key == "ArrowRight" || key == "ArrowUp" ? 1 : -1;
1537
- if (reverse) direction *= -1;
1538
- onChange((0, _tremolo_ui_functions.applyDelta)(value, direction, keyboard, axis));
1539
- }
2512
+ ].includes(key)) return;
2513
+ event.preventDefault();
2514
+ if (!keyboard || !onChange || inactive) return;
2515
+ let direction = key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
2516
+ if (reverse) direction *= -1;
2517
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, keyboard, axis, event));
1540
2518
  }, [
1541
2519
  keyboard,
1542
2520
  onChange,
1543
- readonly,
2521
+ inactive,
1544
2522
  reverse,
1545
2523
  value,
1546
2524
  axis
@@ -1549,36 +2527,37 @@ const Slider = {
1549
2527
  const { refCallback: dragRefCallback } = useDragValue({
1550
2528
  axis,
1551
2529
  baseElementRef: trackRef,
1552
- cursor: readonly ? void 0 : externalStyles.cursor,
2530
+ cursor: inactive ? void 0 : externalStyles.cursor,
2531
+ shouldStart: () => !inactive,
2532
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
1553
2533
  updateOnPointerDown: true,
1554
2534
  onChange: (v) => {
1555
- if (readonly) return;
2535
+ if (inactive) return;
1556
2536
  onChange?.(valueOf(v));
1557
2537
  },
1558
2538
  onDragStart: (v) => {
1559
- if (readonly) return;
1560
- if (externalStyles.userSelectNone) addUserSelectNone();
2539
+ if (inactive) return;
1561
2540
  thumbRef.current?.focus();
1562
2541
  onDragStart?.(valueOf(v));
1563
2542
  },
1564
2543
  onDragEnd: (v) => {
1565
- if (readonly) return;
1566
- if (externalStyles.userSelectNone) removeUserSelectNone();
2544
+ if (inactive) return;
1567
2545
  onDragEnd?.(valueOf(v));
1568
2546
  }
1569
2547
  });
1570
- const rootRefCallback = useComposedRefs(dragRefCallback, useWheel((event) => {
1571
- if (!wheel || !onChange || readonly) return;
2548
+ const wheelRefCallback = useWheel((event) => {
2549
+ if (!wheel || !onChange || inactive) return;
1572
2550
  event.preventDefault();
1573
2551
  let direction;
1574
- if (!vertical && event.deltaX != 0) direction = event.deltaX > 0 ? 1 : -1;
2552
+ if (!vertical && event.deltaX !== 0) direction = event.deltaX > 0 ? 1 : -1;
1575
2553
  else {
1576
- if (event.deltaY == 0) return;
2554
+ if (event.deltaY === 0) return;
1577
2555
  direction = event.deltaY > 0 ? -1 : 1;
1578
2556
  }
1579
- if (vertical && reverse) direction *= -1;
1580
- onChange((0, _tremolo_ui_functions.applyDelta)(value, direction, wheel, axis));
1581
- }, WHEEL_OPTIONS$1));
2557
+ if (reverse) direction *= -1;
2558
+ onChange((0, _tremolo_ui_dom.applyDelta)(value, direction, wheel, axis, event));
2559
+ }, WHEEL_OPTIONS$1);
2560
+ const rootRefCallback = require_compose_refs.useComposedRefs(dragRefCallback, wheelRefCallback);
1582
2561
  const context = (0, react.useMemo)(() => ({
1583
2562
  value,
1584
2563
  min,
@@ -1589,6 +2568,7 @@ const Slider = {
1589
2568
  reverse,
1590
2569
  disabled,
1591
2570
  readonly,
2571
+ onChange,
1592
2572
  percent,
1593
2573
  trackRef,
1594
2574
  thumbRef
@@ -1602,32 +2582,29 @@ const Slider = {
1602
2582
  reverse,
1603
2583
  disabled,
1604
2584
  readonly,
2585
+ onChange,
1605
2586
  percent
1606
2587
  ]);
1607
2588
  (0, react.useImperativeHandle)(forwardedRef, () => {
1608
2589
  return {
1609
2590
  focus() {
1610
- thumbRef.current?.focus();
2591
+ if (!disabled) thumbRef.current?.focus();
1611
2592
  },
1612
2593
  blur() {
1613
2594
  thumbRef.current?.blur();
1614
2595
  }
1615
2596
  };
1616
- }, []);
2597
+ }, [disabled]);
1617
2598
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SliderProvider, {
1618
2599
  value: context,
1619
2600
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1620
- className: (0, clsx.default)("tremolo-slider", className),
2601
+ className,
1621
2602
  ref: rootRefCallback,
2603
+ role: "group",
1622
2604
  tabIndex: -1,
1623
- role: "slider",
1624
- "aria-valuenow": value,
1625
- "aria-valuemin": min,
1626
- "aria-valuemax": max,
1627
- "aria-orientation": vertical ? "vertical" : "horizontal",
1628
- "aria-disabled": disabled,
1629
- "aria-readonly": readonly,
1630
- "data-vertical": vertical,
2605
+ "data-orientation": vertical ? "vertical" : "horizontal",
2606
+ "data-disabled": disabled ? "" : void 0,
2607
+ "data-readonly": readonly ? "" : void 0,
1631
2608
  style,
1632
2609
  onPointerDown,
1633
2610
  onKeyDown: (event) => {
@@ -1635,7 +2612,7 @@ const Slider = {
1635
2612
  onKeyDown?.(event);
1636
2613
  },
1637
2614
  onFocus: (event) => {
1638
- thumbRef.current?.focus();
2615
+ if (!disabled) thumbRef.current?.focus();
1639
2616
  onFocus?.(event);
1640
2617
  },
1641
2618
  onBlur: (event) => {
@@ -1654,7 +2631,7 @@ const Slider = {
1654
2631
  };
1655
2632
  //#endregion
1656
2633
  //#region src/components/XYPad/context.tsx
1657
- const XYPadContext = (0, react.createContext)(null);
2634
+ const XYPadContext = /* @__PURE__ */ (0, react.createContext)(null);
1658
2635
  const XYPadProvider = XYPadContext.Provider;
1659
2636
  function useXYPadContext(selector) {
1660
2637
  const context = (0, react.useContext)(XYPadContext);
@@ -1663,63 +2640,91 @@ function useXYPadContext(selector) {
1663
2640
  }
1664
2641
  //#endregion
1665
2642
  //#region src/components/XYPad/Area.tsx
1666
- function Area({ width = 120, height = 120, color, children, className, style, ref, ...props }) {
2643
+ const Area = /* @__PURE__ */ (0, react.forwardRef)(function Area({ width, height, color, children, className, style, ...props }, forwardedRef) {
1667
2644
  const { areaRef } = useXYPadContext();
2645
+ const composedRef = require_compose_refs.useComposedRefs(forwardedRef, areaRef);
1668
2646
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1669
- ref: useComposedRefs(ref, areaRef),
1670
- className: (0, clsx.default)("tremolo-xy-pad-area", className),
2647
+ ref: composedRef,
2648
+ className,
1671
2649
  style: {
1672
2650
  "--color": color,
1673
- width,
1674
- height,
2651
+ "--width": cssLength(width),
2652
+ "--height": cssLength(height),
2653
+ position: "relative",
1675
2654
  ...style
1676
2655
  },
1677
2656
  ...props,
1678
- children
2657
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Placement, {
2658
+ name: "XYPad.Area",
2659
+ children
2660
+ })
1679
2661
  });
1680
- }
2662
+ });
1681
2663
  //#endregion
1682
2664
  //#region src/components/XYPad/Thumb.tsx
1683
- function Thumb({ color, children, wrapperClassName, wrapperStyle, className, style, ref, ...props }) {
1684
- const elementRef = (0, react.useRef)(null);
1685
- const { disabled, readonly, percent, thumbRef } = useXYPadContext();
2665
+ const Thumb = /* @__PURE__ */ (0, react.forwardRef)(function Thumb({ color, children, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-valuetext": ariaValuetext, ...props }, forwardedRef) {
2666
+ const xInputRef = (0, react.useRef)(null);
2667
+ const yInputRef = (0, react.useRef)(null);
2668
+ const { value, min, max, step, disabled, readonly, onChange, percent, thumbRef } = useXYPadContext();
2669
+ useCheckPlacement("XYPad.Thumb", "XYPad.Area");
2670
+ const labels = (0, _tremolo_ui_dom.toXY)(ariaLabel);
2671
+ const labelledby = (0, _tremolo_ui_dom.toXY)(ariaLabelledby);
2672
+ const describedby = (0, _tremolo_ui_dom.toXY)(ariaDescribedby);
2673
+ const valueText = (0, _tremolo_ui_dom.toXY)(ariaValuetext);
1686
2674
  const methods = () => ({
1687
2675
  focus() {
1688
- elementRef.current?.focus();
2676
+ if (!disabled) xInputRef.current?.focus();
1689
2677
  },
1690
2678
  blur() {
1691
- elementRef.current?.blur();
2679
+ xInputRef.current?.blur();
2680
+ yInputRef.current?.blur();
1692
2681
  }
1693
2682
  });
1694
- (0, react.useImperativeHandle)(ref, methods, []);
1695
- (0, react.useImperativeHandle)(thumbRef, methods, []);
1696
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1697
- className: (0, clsx.default)("tremolo-xy-pad-thumb-wrapper", wrapperClassName),
2683
+ (0, react.useImperativeHandle)(forwardedRef, methods, [disabled]);
2684
+ (0, react.useImperativeHandle)(thumbRef, methods, [disabled]);
2685
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2686
+ className,
2687
+ "data-disabled": disabled ? "" : void 0,
2688
+ "data-readonly": readonly ? "" : void 0,
2689
+ ...props,
1698
2690
  style: {
2691
+ "--color": color,
2692
+ position: "absolute",
2693
+ translate: "var(--translate, -50% -50%)",
2694
+ zIndex: 100,
2695
+ ...style,
1699
2696
  left: `${percent[0]}%`,
1700
- top: `${percent[1]}%`,
1701
- ...wrapperStyle
2697
+ top: `${percent[1]}%`
1702
2698
  },
1703
- ...props,
1704
- children: children ? children : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1705
- ref: elementRef,
1706
- className: (0, clsx.default)("tremolo-xy-pad-thumb", className),
1707
- tabIndex: 0,
1708
- "aria-disabled": disabled,
2699
+ children: [[0, 1].map((axis) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VisuallyHiddenRangeInput, {
2700
+ ref: axis === 0 ? xInputRef : yInputRef,
2701
+ "data-axis": axis === 0 ? "x" : "y",
2702
+ value: value[axis],
2703
+ min: min[axis],
2704
+ max: max[axis],
2705
+ step: step[axis],
2706
+ disabled,
1709
2707
  "aria-readonly": readonly,
1710
- style: {
1711
- "--color": color,
1712
- ...style
2708
+ "aria-orientation": axis === 0 ? "horizontal" : "vertical",
2709
+ "aria-label": labels[axis] ?? (axis === 0 ? "x" : "y"),
2710
+ "aria-labelledby": labelledby[axis],
2711
+ "aria-describedby": describedby[axis],
2712
+ "aria-valuetext": valueText[axis],
2713
+ onChange: (event) => {
2714
+ if (readonly) {
2715
+ event.currentTarget.value = String(value[axis]);
2716
+ return;
2717
+ }
2718
+ const next = [...value];
2719
+ next[axis] = event.currentTarget.valueAsNumber;
2720
+ onChange?.(next);
1713
2721
  }
1714
- })
2722
+ }, axis)), children]
1715
2723
  });
1716
- }
2724
+ });
1717
2725
  //#endregion
1718
2726
  //#region src/components/XYPad/index.tsx
1719
- const defaultExternalStyles = {
1720
- userSelectNone: true,
1721
- cursor: "pointer"
1722
- };
2727
+ const defaultExternalStyles = { cursor: "pointer" };
1723
2728
  /**
1724
2729
  * The wheel only acts while the focus is inside, so that scrolling a page past
1725
2730
  * the control does not change its value.
@@ -1729,7 +2734,7 @@ const WHEEL_OPTIONS = { requireFocus: true };
1729
2734
  * Simple XYPad
1730
2735
  */
1731
2736
  const XYPad = {
1732
- Root: (0, react.forwardRef)(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = _tremolo_ui_functions.linearScale, reverse: _reverse = false, wheel = ["raw", 1], keyboard = ["raw", 1], className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
2737
+ Root: /* @__PURE__ */ (0, react.forwardRef)(({ value, min: _min, max: _max, step: _step = 1, scale: _scale = _tremolo_ui_functions.linearScale, reverse: _reverse = false, wheel = DEFAULT_WHEEL_OPTIONS, keyboard = DEFAULT_KEYBOARD_OPTIONS, dragSensitivity = DEFAULT_DRAG_SENSITIVITY, className, style, externalStyles: _externalStyles, disabled = false, readonly = false, onChange, onDragStart, onDragEnd, onPointerDown, onKeyDown, onFocus, onBlur, children, ...props }, forwardedRef) => {
1733
2738
  const rootRef = (0, react.useRef)(null);
1734
2739
  const areaRef = (0, react.useRef)(null);
1735
2740
  const thumbRef = (0, react.useRef)(null);
@@ -1737,6 +2742,7 @@ const XYPad = {
1737
2742
  ...defaultExternalStyles,
1738
2743
  ..._externalStyles
1739
2744
  };
2745
+ const inactive = disabled || readonly;
1740
2746
  const min = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_min), [_min]);
1741
2747
  const max = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_max), [_max]);
1742
2748
  const step = (0, react.useMemo)(() => (0, _tremolo_ui_dom.toXY)(_step), [_step]);
@@ -1765,32 +2771,45 @@ const XYPad = {
1765
2771
  scale,
1766
2772
  reverse
1767
2773
  ]);
1768
- const withAxis = (0, react.useCallback)((axis, next) => axis == 0 ? [next, value[1]] : [value[0], next], [value]);
2774
+ useCheckSteps({
2775
+ component: "XYPad",
2776
+ axis: "x",
2777
+ range: axis[0],
2778
+ keyboard,
2779
+ wheel
2780
+ });
2781
+ useCheckSteps({
2782
+ component: "XYPad",
2783
+ axis: "y",
2784
+ range: axis[1],
2785
+ keyboard,
2786
+ wheel
2787
+ });
2788
+ const withAxis = (0, react.useCallback)((axis, next) => axis === 0 ? [next, value[1]] : [value[0], next], [value]);
1769
2789
  /** @param i 0 = x, 1 = y */
1770
- const nudge = (0, react.useCallback)((i, direction, option) => withAxis(i, (0, _tremolo_ui_functions.applyDelta)(value[i], direction, option, axis[i])), [
2790
+ const nudge = (0, react.useCallback)((i, direction, option, modifiers) => withAxis(i, (0, _tremolo_ui_dom.applyDelta)(value[i], direction, option, axis[i], modifiers)), [
1771
2791
  value,
1772
2792
  axis,
1773
2793
  withAxis
1774
2794
  ]);
1775
2795
  const handleKeyDown = (0, react.useCallback)((event) => {
1776
- if (!onChange || readonly || !keyboard) return;
1777
2796
  const key = event.key;
1778
- if ([
2797
+ if (![
1779
2798
  "ArrowRight",
1780
2799
  "ArrowLeft",
1781
2800
  "ArrowUp",
1782
2801
  "ArrowDown"
1783
- ].includes(key)) {
1784
- const i = key == "ArrowRight" || key == "ArrowLeft" ? 0 : 1;
1785
- event.preventDefault();
1786
- let direction = 1;
1787
- if (key == "ArrowLeft" || key == "ArrowUp") direction *= -1;
1788
- if (reverse[i]) direction *= -1;
1789
- onChange(nudge(i, direction, keyboard));
1790
- }
2802
+ ].includes(key)) return;
2803
+ const i = key === "ArrowRight" || key === "ArrowLeft" ? 0 : 1;
2804
+ event.preventDefault();
2805
+ if (!onChange || inactive || !keyboard) return;
2806
+ let direction = 1;
2807
+ if (key === "ArrowLeft" || key === "ArrowUp") direction *= -1;
2808
+ if (reverse[i]) direction *= -1;
2809
+ onChange(nudge(i, direction, keyboard, event));
1791
2810
  }, [
1792
2811
  onChange,
1793
- readonly,
2812
+ inactive,
1794
2813
  keyboard,
1795
2814
  reverse,
1796
2815
  nudge
@@ -1798,33 +2817,37 @@ const XYPad = {
1798
2817
  const { refCallback: dragRefCallback } = useDragValue({
1799
2818
  axis,
1800
2819
  baseElementRef: areaRef,
2820
+ sensitivity: (state) => (0, _tremolo_ui_dom.selectModifier)(dragSensitivity, state.event).value,
1801
2821
  updateOnPointerDown: true,
1802
- cursor: readonly ? void 0 : externalStyles.cursor,
2822
+ cursor: inactive ? void 0 : externalStyles.cursor,
2823
+ shouldStart: () => !inactive,
1803
2824
  onChange: (v) => {
1804
- if (readonly) return;
2825
+ if (inactive) return;
1805
2826
  onChange?.(v);
1806
2827
  },
1807
2828
  onDragStart: (v) => {
1808
- if (readonly) return;
1809
- if (externalStyles.userSelectNone) addUserSelectNone();
2829
+ if (inactive) return;
1810
2830
  thumbRef.current?.focus();
1811
2831
  onDragStart?.(v);
1812
2832
  },
1813
2833
  onDragEnd: (v) => {
1814
- if (readonly) return;
1815
- if (externalStyles.userSelectNone) removeUserSelectNone();
2834
+ if (inactive) return;
1816
2835
  onDragEnd?.(v);
1817
2836
  }
1818
2837
  });
1819
- const rootRefCallback = useComposedRefs(rootRef, dragRefCallback, useWheel((event) => {
1820
- if (!onChange || readonly || !wheel) return;
1821
- const i = event.shiftKey ? 0 : 1;
2838
+ const wheelRefCallback = useWheel((event) => {
2839
+ if (!onChange || inactive || !wheel) return;
2840
+ const horizontal = event.deltaX !== 0;
2841
+ const delta = horizontal ? event.deltaX : event.deltaY;
2842
+ if (delta === 0) return;
2843
+ const i = horizontal || event.shiftKey ? 0 : 1;
1822
2844
  event.preventDefault();
1823
2845
  let direction = 1;
1824
- if (event.deltaY < 0) direction *= -1;
2846
+ if (delta < 0) direction *= -1;
1825
2847
  if (reverse[i]) direction *= -1;
1826
- onChange(nudge(i, direction, wheel));
1827
- }, WHEEL_OPTIONS));
2848
+ onChange(nudge(i, direction, wheel, event));
2849
+ }, WHEEL_OPTIONS);
2850
+ const rootRefCallback = require_compose_refs.useComposedRefs(rootRef, dragRefCallback, wheelRefCallback);
1828
2851
  const context = (0, react.useMemo)(() => ({
1829
2852
  value,
1830
2853
  min,
@@ -1834,6 +2857,7 @@ const XYPad = {
1834
2857
  reverse,
1835
2858
  disabled,
1836
2859
  readonly,
2860
+ onChange,
1837
2861
  percent,
1838
2862
  areaRef,
1839
2863
  thumbRef
@@ -1846,27 +2870,29 @@ const XYPad = {
1846
2870
  reverse,
1847
2871
  disabled,
1848
2872
  readonly,
2873
+ onChange,
1849
2874
  percent
1850
2875
  ]);
1851
2876
  (0, react.useImperativeHandle)(forwardedRef, () => {
1852
2877
  return {
1853
2878
  focus() {
1854
- thumbRef.current?.focus();
2879
+ if (!disabled) thumbRef.current?.focus();
1855
2880
  },
1856
2881
  blur() {
1857
2882
  thumbRef.current?.blur();
1858
2883
  },
1859
2884
  original: rootRef
1860
2885
  };
1861
- }, []);
2886
+ }, [disabled]);
1862
2887
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(XYPadProvider, {
1863
2888
  value: context,
1864
2889
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1865
- className: (0, clsx.default)("tremolo-xy-pad", className),
2890
+ className,
1866
2891
  ref: rootRefCallback,
2892
+ role: "group",
1867
2893
  tabIndex: -1,
1868
- "aria-disabled": disabled,
1869
- "aria-readonly": readonly,
2894
+ "data-disabled": disabled ? "" : void 0,
2895
+ "data-readonly": readonly ? "" : void 0,
1870
2896
  style,
1871
2897
  onPointerDown,
1872
2898
  onKeyDown: (event) => {
@@ -1874,7 +2900,7 @@ const XYPad = {
1874
2900
  onKeyDown?.(event);
1875
2901
  },
1876
2902
  onFocus: (event) => {
1877
- thumbRef.current?.focus();
2903
+ if (!disabled) thumbRef.current?.focus();
1878
2904
  onFocus?.(event);
1879
2905
  },
1880
2906
  onBlur: (event) => {
@@ -1893,68 +2919,159 @@ const XYPad = {
1893
2919
  //#region src/hooks/useAnimationFrame.ts
1894
2920
  function useAnimationFrame(callback = () => {}, deps = []) {
1895
2921
  const reqIdRef = (0, react.useRef)(-1);
1896
- const loop = (0, react.useCallback)(() => {
1897
- reqIdRef.current = requestAnimationFrame(loop);
1898
- callback();
1899
- }, [callback, ...deps]);
2922
+ const runCallback = useCallbackRef(callback);
1900
2923
  (0, react.useEffect)(() => {
2924
+ const loop = () => {
2925
+ reqIdRef.current = requestAnimationFrame(loop);
2926
+ runCallback();
2927
+ };
1901
2928
  reqIdRef.current = requestAnimationFrame(loop);
1902
2929
  return () => cancelAnimationFrame(reqIdRef.current);
1903
- }, [loop]);
2930
+ }, [runCallback, ...deps]);
1904
2931
  }
1905
2932
  //#endregion
1906
2933
  //#region src/hooks/useMIDIAccess.ts
2934
+ const INITIAL_STATE = {
2935
+ midiAccess: null,
2936
+ error: null,
2937
+ inputs: []
2938
+ };
2939
+ function createMIDIAccessStore() {
2940
+ let instance = null;
2941
+ let unsubscribe = null;
2942
+ let state = INITIAL_STATE;
2943
+ const listeners = /* @__PURE__ */ new Set();
2944
+ function emit() {
2945
+ for (const listener of listeners) listener();
2946
+ }
2947
+ function connect() {
2948
+ const nextInstance = (0, _tremolo_ui_dom.createMIDIAccess)();
2949
+ instance = nextInstance;
2950
+ state = nextInstance.getState();
2951
+ unsubscribe = nextInstance.subscribe(() => {
2952
+ state = nextInstance.getState();
2953
+ emit();
2954
+ });
2955
+ emit();
2956
+ return nextInstance;
2957
+ }
2958
+ function disconnect(currentInstance) {
2959
+ if (instance !== currentInstance) return;
2960
+ unsubscribe?.();
2961
+ unsubscribe = null;
2962
+ instance = null;
2963
+ state = INITIAL_STATE;
2964
+ currentInstance.destroy();
2965
+ emit();
2966
+ }
2967
+ function request(options) {
2968
+ instance?.request(options);
2969
+ }
2970
+ return {
2971
+ connect,
2972
+ disconnect,
2973
+ request,
2974
+ getState: () => state,
2975
+ getServerState: () => INITIAL_STATE,
2976
+ subscribe: (listener) => {
2977
+ listeners.add(listener);
2978
+ return () => listeners.delete(listener);
2979
+ }
2980
+ };
2981
+ }
1907
2982
  /**
1908
2983
  * Hooks for requesting MIDI access in the browser. The first argument allows you to choose whether to request access on mount.
2984
+ *
2985
+ * `inputs` follows the devices: it changes as one is plugged in or unplugged,
2986
+ * so a list of devices needs no `statechange` listener of its own.
2987
+ *
2988
+ * `request` takes `{ sysex: true }` when the app needs system exclusive
2989
+ * messages. Browsers treat that as a separate, more sensitive permission, so
2990
+ * ask for it only when it is actually used.
1909
2991
  */
1910
2992
  function useMIDIAccess(requestOnMount = true) {
1911
- const instance = (0, react.useMemo)(() => (0, _tremolo_ui_dom.createMIDIAccess)(), []);
1912
- const { midiAccess, error } = (0, react.useSyncExternalStore)(instance.subscribe, instance.getState, instance.getServerState);
2993
+ const store = (0, react.useMemo)(() => createMIDIAccessStore(), []);
2994
+ const { midiAccess, error, inputs } = (0, react.useSyncExternalStore)(store.subscribe, store.getState, store.getServerState);
1913
2995
  (0, react.useEffect)(() => {
1914
- if (requestOnMount) instance.request();
1915
- }, [instance, requestOnMount]);
2996
+ const instance = store.connect();
2997
+ return () => store.disconnect(instance);
2998
+ }, [store]);
1916
2999
  (0, react.useEffect)(() => {
1917
- return () => instance.destroy();
1918
- }, [instance]);
3000
+ if (requestOnMount) store.request();
3001
+ }, [store, requestOnMount]);
1919
3002
  return {
1920
- request: instance.request,
3003
+ request: store.request,
1921
3004
  midiAccess,
1922
- error
3005
+ error,
3006
+ inputs
1923
3007
  };
1924
3008
  }
1925
3009
  //#endregion
1926
3010
  //#region src/hooks/useMIDIInput.ts
1927
3011
  /**
1928
- * Hooks for handling note on/off events. To be used with useMIDIAccess. Internally uses useMIDIMessage.
3012
+ * Handle MIDI input events. To be used with {@link useMIDIAccess}.
3013
+ *
3014
+ * The handlers are read fresh on every event, so writing them inline is fine:
3015
+ * the listeners are attached once per `midiAccess` and stay attached. Devices
3016
+ * plugged in later are picked up without anything having to be rebuilt.
3017
+ *
3018
+ * @example
3019
+ * const { midiAccess } = useMIDIAccess()
3020
+ * useMIDIInput(midiAccess, {
3021
+ * onNoteOnEvent: (note, velocity) => play(note, velocity / 127),
3022
+ * onNoteOffEvent: (note) => stop(note),
3023
+ * onControlChangeEvent: (controller, value) => {
3024
+ * if (controller === 1) setModulation(value / 127)
3025
+ * },
3026
+ * })
1929
3027
  */
1930
- function useMIDIInput(midiAccess, onNoteOnEvent, onNoteOffEvent, onPitchBendEvent) {
3028
+ function useMIDIInput(midiAccess, handlers) {
3029
+ const latest = (0, react.useRef)(handlers);
3030
+ const instanceRef = (0, react.useRef)(null);
1931
3031
  (0, react.useEffect)(() => {
1932
- const instance = (0, _tremolo_ui_dom.createMIDIInput)(midiAccess, {
1933
- onNoteOnEvent,
1934
- onNoteOffEvent,
1935
- onPitchBendEvent
1936
- });
1937
- return () => instance.destroy();
1938
- }, [
1939
- midiAccess,
1940
- onNoteOnEvent,
1941
- onNoteOffEvent,
1942
- onPitchBendEvent
1943
- ]);
3032
+ const instance = (0, _tremolo_ui_dom.createMIDIInput)(midiAccess, latest.current);
3033
+ instanceRef.current = instance;
3034
+ return () => {
3035
+ instanceRef.current = null;
3036
+ instance.destroy();
3037
+ };
3038
+ }, [midiAccess]);
3039
+ (0, react.useEffect)(() => {
3040
+ latest.current = handlers;
3041
+ instanceRef.current?.update(handlers);
3042
+ });
1944
3043
  }
1945
3044
  //#endregion
1946
3045
  //#region src/hooks/useMIDIMessage.ts
1947
3046
  /**
1948
3047
  * Hooks for when you want to process MIDI events in more detail than useMIDIInput.
3048
+ *
3049
+ * The handler is read fresh on every event, so writing it inline is fine: the
3050
+ * listeners are attached once per `midiAccess` and stay attached, and devices
3051
+ * plugged in later are picked up.
1949
3052
  */
1950
3053
  function useMIDIMessage(midiAccess, onMIDIMessage) {
3054
+ const latest = (0, react.useRef)(onMIDIMessage);
3055
+ const instanceRef = (0, react.useRef)(null);
1951
3056
  (0, react.useEffect)(() => {
1952
- const instance = (0, _tremolo_ui_dom.createMIDIMessage)(midiAccess, onMIDIMessage);
1953
- return () => instance.destroy();
1954
- }, [midiAccess, onMIDIMessage]);
3057
+ const instance = (0, _tremolo_ui_dom.createMIDIMessage)(midiAccess, (event) => latest.current(event));
3058
+ instanceRef.current = instance;
3059
+ return () => {
3060
+ instanceRef.current = null;
3061
+ instance.destroy();
3062
+ };
3063
+ }, [midiAccess]);
3064
+ (0, react.useEffect)(() => {
3065
+ latest.current = onMIDIMessage;
3066
+ });
1955
3067
  }
1956
3068
  //#endregion
1957
3069
  exports.AnimationCanvas = AnimationCanvas;
3070
+ exports.DEFAULT_DRAG_SENSITIVITY = DEFAULT_DRAG_SENSITIVITY;
3071
+ exports.DEFAULT_KEYBOARD_OPTIONS = DEFAULT_KEYBOARD_OPTIONS;
3072
+ exports.DEFAULT_WHEEL_OPTIONS = DEFAULT_WHEEL_OPTIONS;
3073
+ exports.DropZone = DropZone;
3074
+ exports.FileInput = FileInput;
1958
3075
  exports.Knob = Knob;
1959
3076
  Object.defineProperty(exports, "NOT_SUPPORTED", {
1960
3077
  enumerable: true,
@@ -1969,17 +3086,27 @@ Object.defineProperty(exports, "PERMISSION_DENIED", {
1969
3086
  return _tremolo_ui_dom.PERMISSION_DENIED;
1970
3087
  }
1971
3088
  });
3089
+ exports.PITCH_BEND_CENTER = _tremolo_ui_dom.PITCH_BEND_CENTER;
1972
3090
  exports.Piano = Piano;
1973
3091
  exports.PointsEditor = PointsEditor;
1974
3092
  exports.SHORTCUTS = SHORTCUTS;
1975
3093
  exports.Slider = Slider;
3094
+ Object.defineProperty(exports, "UNAVAILABLE", {
3095
+ enumerable: true,
3096
+ get: function() {
3097
+ return _tremolo_ui_dom.UNAVAILABLE;
3098
+ }
3099
+ });
1976
3100
  exports.XYPad = XYPad;
1977
3101
  exports.clampPoint = clampPoint;
1978
3102
  exports.useAnimationFrame = useAnimationFrame;
1979
3103
  exports.useDrag = useDrag;
1980
3104
  exports.useDragValue = useDragValue;
3105
+ exports.useDropZone = useDropZone;
1981
3106
  exports.useEventListener = useEventListener;
3107
+ exports.useFileInputContext = useFileInputContext;
1982
3108
  exports.useInterval = useInterval;
3109
+ exports.useKnobContext = useKnobContext;
1983
3110
  exports.useLongPress = useLongPress;
1984
3111
  exports.useMIDIAccess = useMIDIAccess;
1985
3112
  exports.useMIDIInput = useMIDIInput;
@@ -1988,5 +3115,6 @@ exports.useNumberInputContext = useNumberInputContext;
1988
3115
  exports.usePointsEditorContext = usePointsEditorContext;
1989
3116
  exports.useSliderContext = useSliderContext;
1990
3117
  exports.useWheel = useWheel;
3118
+ exports.useXYPadContext = useXYPadContext;
1991
3119
 
1992
3120
  //# sourceMappingURL=index.cjs.map