@tremolo-ui/react 0.5.0 → 0.6.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 (76) 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 +1468 -534
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1103 -435
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1103 -435
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1457 -534
  16. package/dist/index.js.map +1 -1
  17. package/package.json +28 -26
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/Knob/ActiveLine.tsx +7 -6
  20. package/src/components/Knob/InactiveLine.tsx +9 -10
  21. package/src/components/Knob/SVGRoot.tsx +5 -3
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +17 -1
  24. package/src/components/Knob/index.tsx +148 -55
  25. package/src/components/NumberInput/DecrementStepper.tsx +8 -4
  26. package/src/components/NumberInput/IncrementStepper.tsx +9 -4
  27. package/src/components/NumberInput/InputField.tsx +188 -105
  28. package/src/components/NumberInput/Stepper.tsx +123 -72
  29. package/src/components/NumberInput/{stepperButton.tsx → StepperButton.tsx} +27 -22
  30. package/src/components/NumberInput/context.tsx +35 -8
  31. package/src/components/NumberInput/index.tsx +272 -75
  32. package/src/components/Piano/index.tsx +354 -188
  33. package/src/components/PointsEditor/Background.tsx +11 -2
  34. package/src/components/PointsEditor/Container.tsx +87 -27
  35. package/src/components/PointsEditor/Point.tsx +239 -79
  36. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  37. package/src/components/PointsEditor/context.tsx +78 -10
  38. package/src/components/PointsEditor/index.tsx +440 -39
  39. package/src/components/Slider/Marks.tsx +48 -16
  40. package/src/components/Slider/MarksOption.tsx +57 -43
  41. package/src/components/Slider/Thumb.tsx +108 -50
  42. package/src/components/Slider/Track.tsx +69 -58
  43. package/src/components/Slider/context.tsx +4 -2
  44. package/src/components/Slider/index.tsx +131 -65
  45. package/src/components/Slider/type.ts +26 -8
  46. package/src/components/XYPad/Area.tsx +45 -35
  47. package/src/components/XYPad/Thumb.tsx +135 -62
  48. package/src/components/XYPad/context.tsx +4 -1
  49. package/src/components/XYPad/index.tsx +180 -48
  50. package/src/components/_util/Placement.tsx +68 -0
  51. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  52. package/src/components/_util/css-length.ts +13 -0
  53. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  54. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  55. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  56. package/src/hooks/useAnimationFrame.ts +17 -7
  57. package/src/hooks/useDrag.ts +38 -8
  58. package/src/hooks/useDragValue.ts +54 -9
  59. package/src/hooks/useEventListener.ts +10 -6
  60. package/src/hooks/useLongPress.ts +42 -8
  61. package/src/hooks/useMIDIAccess.ts +82 -11
  62. package/src/hooks/useMIDIInput.ts +45 -13
  63. package/src/hooks/useMIDIMessage.ts +23 -5
  64. package/src/index.ts +38 -21
  65. package/src/input-event.ts +32 -0
  66. package/dist/index.css +0 -501
  67. package/src/Globals.d.ts +0 -3
  68. package/src/components/Knob/index.css +0 -81
  69. package/src/components/NumberInput/index.css +0 -121
  70. package/src/components/Piano/index.css +0 -90
  71. package/src/components/PointsEditor/index.css +0 -35
  72. package/src/components/Slider/index.css +0 -125
  73. package/src/components/XYPad/index.css +0 -60
  74. package/src/components/_util/index.ts +0 -28
  75. package/src/styles/global.css +0 -19
  76. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
@@ -1,6 +1,12 @@
1
1
  import { createContext, RefObject, useContext } from 'react'
2
2
 
3
- import type { InputEventOption, Scale, ValueRange } from '@tremolo-ui/functions'
3
+ import type {
4
+ InputEventOption,
5
+ ModifierState,
6
+ ModifierValue,
7
+ Scale,
8
+ ValueRange,
9
+ } from '@tremolo-ui/functions'
4
10
 
5
11
  export type NumberInputContextValue = {
6
12
  value: number
@@ -21,14 +27,29 @@ export type NumberInputContextValue = {
21
27
  clampValue: boolean
22
28
 
23
29
  /**
24
- * The effective scaling for `applyDelta` and for clamping on commit, with the
25
- * unbounded ends filled in. Ignores `min` / `max` when `clampValue` is off.
30
+ * The effective scaling for `applyDelta`, with unbounded ends filled in.
31
+ * Ignores `min` / `max` when `clampValue` is off.
26
32
  */
27
33
  range: ValueRange
34
+ /** The effective range for raw input, whose open ends cover finite numbers. */
35
+ rawRange: ValueRange
28
36
 
29
- keyboard: InputEventOption | null
37
+ keyboard: ModifierValue<InputEventOption> | null
30
38
  /** Pixels of vertical drag on `Stepper` that move the value by one `step`. */
31
39
  drag: number | null
40
+ /** How much a `Stepper` drag counts, per modifier key. */
41
+ dragSensitivity: ModifierValue<number>
42
+ /** Whether a `Stepper` drag hides the pointer and reads its movement. */
43
+ pointerLock: boolean
44
+
45
+ /** What `InputField` selects when it takes focus. */
46
+ selectOnFocus: 'all' | 'number' | 'none'
47
+ /** Whether `InputField` shows the plain value while it has focus. */
48
+ unformatOnFocus: boolean
49
+ /** Whether `InputField` puts the caret back after an arrow key steps the value. */
50
+ keepCaretOnStep: boolean
51
+ /** Whether Enter leaves `InputField` as well as committing. */
52
+ blurOnEnter: boolean
32
53
 
33
54
  /** What the input shows: the draft while editing, the formatted value otherwise. */
34
55
  text: string
@@ -44,18 +65,23 @@ export type NumberInputContextValue = {
44
65
 
45
66
  /** Typing. Replaces the draft and reports the parsed value, unclamped. */
46
67
  setDraft: (text: string) => void
47
- /** Blur or Enter. Clamps the draft, reports it, and drops the draft. */
68
+ /** Blur or Enter. Optionally clamps the draft, reports it, and drops it. */
48
69
  commitDraft: () => void
49
70
  /** Drops the draft and replaces the value: steppers, wheel, keyboard, drag. */
50
71
  changeValue: (next: number) => void
51
72
  /** Moves the value by one `option` in `direction`, normally +1 or -1. */
52
- nudge: (direction: number, option: InputEventOption) => void
73
+ nudge: (
74
+ direction: number,
75
+ option: ModifierValue<InputEventOption>,
76
+ modifiers?: ModifierState,
77
+ ) => void
53
78
 
54
79
  /** `InputField` registers itself here; `Root` focuses it through its methods. */
55
80
  inputRef: RefObject<HTMLInputElement | null>
56
81
  }
57
82
 
58
- const NumberInputContext = createContext<NumberInputContextValue | null>(null)
83
+ const NumberInputContext =
84
+ /* @__PURE__ */ createContext<NumberInputContextValue | null>(null)
59
85
 
60
86
  export const NumberInputProvider = NumberInputContext.Provider
61
87
 
@@ -81,7 +107,8 @@ export type StepperContextValue = {
81
107
  draggingRef: RefObject<boolean>
82
108
  }
83
109
 
84
- const StepperContext = createContext<StepperContextValue | null>(null)
110
+ const StepperContext =
111
+ /* @__PURE__ */ createContext<StepperContextValue | null>(null)
85
112
 
86
113
  export const StepperProvider = StepperContext.Provider
87
114
 
@@ -1,4 +1,3 @@
1
- import clsx from 'clsx'
2
1
  import {
3
2
  ComponentPropsWithoutRef,
4
3
  CSSProperties,
@@ -13,17 +12,22 @@ import {
13
12
 
14
13
  import {
15
14
  applyDelta,
16
- clamp,
17
- formatValue,
18
15
  InputEventOption,
16
+ ModifierState,
17
+ type ModifierValue,
19
18
  linearScale,
20
- parseValue,
19
+ selectInputEvent,
21
20
  type Scale,
22
- type Units,
23
21
  type ValueRange,
24
22
  } from '@tremolo-ui/functions'
25
23
 
24
+ import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
26
25
  import { useWheel } from '../../hooks/useWheel'
26
+ import {
27
+ DEFAULT_DRAG_SENSITIVITY,
28
+ DEFAULT_KEYBOARD_OPTIONS,
29
+ DEFAULT_WHEEL_OPTIONS,
30
+ } from '../../input-event'
27
31
 
28
32
  import { NumberInputProvider } from './context'
29
33
  import { DecrementStepper } from './DecrementStepper'
@@ -38,8 +42,27 @@ export interface NumberInputProps {
38
42
  */
39
43
  value: number
40
44
 
45
+ /**
46
+ * Lowest value. Leave it out for no lower bound.
47
+ *
48
+ * It is enforced when a value is committed or stepped, while `clampValue` is
49
+ * on. A value below it carries `data-out-of-range` on `InputField`.
50
+ */
41
51
  min?: number
52
+ /**
53
+ * Highest value. Leave it out for no upper bound.
54
+ *
55
+ * It is enforced when a value is committed or stepped, while `clampValue` is
56
+ * on. A value above it carries `data-out-of-range` on `InputField`.
57
+ */
42
58
  max?: number
59
+ /**
60
+ * Granularity of the value. The steppers, a drag on `Stepper`, the wheel and
61
+ * the arrow keys snap it to multiples of `step`; a typed value is left as it
62
+ * is.
63
+ *
64
+ * @default 1
65
+ */
43
66
  step?: number
44
67
  /**
45
68
  * How the value is distributed across the travel of a drag or a
@@ -50,26 +73,20 @@ export interface NumberInputProps {
50
73
  scale?: Scale
51
74
 
52
75
  /**
53
- * Unit to display the value in, or a list to pick from by magnitude.
54
- * Builds the default `format` and `parse`.
76
+ * Render the value as text. Plain digits by default.
77
+ *
78
+ * `unitFormat` from `@tremolo-ui/functions` builds this and `parse` together
79
+ * for a unit, as a pair to spread into the root.
55
80
  *
56
81
  * @example
57
- * units='Hz'
58
- * units={[['Hz', 1], ['kHz', 1000]]}
59
- * units={[['ms', 1], ['s', 1000]]}
82
+ * <NumberInput.Root {...unitFormat('Hz', { digits: 2 })} value={v} />
60
83
  */
61
- units?: string | Units
84
+ format?: (value: number) => string
62
85
  /**
63
- * Digits after the decimal point, for the default `format`.
86
+ * Read a value back out of the text. Has to undo `format`.
64
87
  *
65
- * @example
66
- * // units={[['Hz', 1], ['kHz', 1000]]} digit={2}
67
- * // 100 -> 100.00Hz, 1600 -> 1.60kHz
88
+ * Text with no number in it reads as `NaN`, which leaves the value alone.
68
89
  */
69
- digit?: number
70
- /** Render the value as text. Takes precedence over `units` / `digit`. */
71
- format?: (value: number) => string
72
- /** Read a value back out of the text. Takes precedence over `units`. */
73
90
  parse?: (text: string) => number
74
91
 
75
92
  /**
@@ -80,37 +97,135 @@ export interface NumberInputProps {
80
97
  clampValue?: boolean
81
98
 
82
99
  /**
83
- * Wheel control option. Only applies while the focus is inside, so that
84
- * scrolling past the input does not change it.
85
- * If null, no event will be triggered
100
+ * How much one notch of the wheel moves the value. It only acts while the
101
+ * focus is inside, so that scrolling the page past the input leaves it
102
+ * alone.
103
+ *
104
+ * `['raw', n]` moves the value by `n`, and `['normalized', n]` by `n` of the
105
+ * range between `min` and `max`. The result is snapped to `step`, except for
106
+ * an amount set on a modifier key (`{ default: …, shift: … }`). `null` turns
107
+ * the wheel off.
108
+ *
109
+ * @default ['raw', 1]
86
110
  */
87
- wheel?: InputEventOption | null
111
+ wheel?: ModifierValue<InputEventOption> | null
88
112
  /**
89
- * keyboard control option
90
- * If null, no event will be triggered
113
+ * How much one arrow key press moves the value.
114
+ *
115
+ * `['raw', n]` moves the value by `n`, and `['normalized', n]` by `n` of the
116
+ * range between `min` and `max`. The result is snapped to `step`, except for
117
+ * an amount set on a modifier key, which is what lets shift move off the
118
+ * grid. `null` turns the arrow keys off.
119
+ *
120
+ * The default moves by 1, and by 0.1 with shift. With a `step` above 1, raise
121
+ * the amount to match: 1 would round straight back to where it started, and
122
+ * a development build warns about it.
123
+ *
124
+ * @default { default: ['raw', 1], shift: ['raw', 0.1] }
91
125
  */
92
- keyboard?: InputEventOption | null
126
+ keyboard?: ModifierValue<InputEventOption> | null
93
127
  /**
94
128
  * Pixels of vertical drag on `Stepper` that move the value by one `step`.
95
- * If null, no event will be triggered
129
+ * `null` turns the drag off.
96
130
  * @default 1
97
131
  */
98
132
  drag?: number | null
99
133
 
100
134
  /**
101
- * Only the appearance will change.
102
- * Please consider using with readonly.
103
- * aria-disabled property is also applied.
135
+ * How much a `Stepper` drag counts, per modifier key.
136
+ *
137
+ * `1` is `drag` pixels per `step`; `0.1` makes the same movement cover a
138
+ * tenth of that. Shift is bound to `0.1` by default, to match what it does
139
+ * on the arrow keys.
140
+ *
141
+ * A modifier entry is not snapped to `step`, which is what lets a finer
142
+ * amount move at all.
143
+ *
144
+ * @default { default: 1, shift: 0.1 }
145
+ */
146
+ dragSensitivity?: ModifierValue<number>
147
+
148
+ /**
149
+ * Hide the cursor while dragging a `Stepper` and read the pointer movement
150
+ * directly, rather than letting it wander off across the screen.
151
+ *
152
+ * The drag is already relative, so the pointer position carries nothing —
153
+ * but it still runs into the edge of the screen, where the operating system
154
+ * pins it and the coordinates stop changing.
155
+ *
156
+ * Off by default: the browser shows its own notice, Esc takes the lock back,
157
+ * and the request can be refused. A refused request is not an error, and the
158
+ * drag carries on as an ordinary one.
159
+ *
160
+ * @default false
161
+ */
162
+ pointerLock?: boolean
163
+
164
+ /**
165
+ * Select the text when `InputField` takes focus: `'all'` selects all of it,
166
+ * `'number'` only the leading number, leaving whatever the format appended,
167
+ * and `'none'` leaves the caret where the click put it.
168
+ * @default 'none'
169
+ */
170
+ selectOnFocus?: 'all' | 'number' | 'none'
171
+ /**
172
+ * Show the plain value while `InputField` has focus, dropping whatever
173
+ * `format` put around it: an input reading `1.23kHz` shows `1230` to be
174
+ * typed over.
175
+ *
176
+ * The number shown is the value itself, not the number inside the formatted
177
+ * text. Those differ whenever the format scales — `1.23` out of `1.23kHz`
178
+ * would read back as 1.23 and lose a factor of a thousand — and it is also
179
+ * why a rounded display no longer becomes the value: `1.6` shown as `2Hz`
180
+ * offers `1.6` for editing, not `2`.
181
+ *
182
+ * @default false
183
+ */
184
+ unformatOnFocus?: boolean
185
+ /**
186
+ * Put the caret in `InputField` back where it was after an arrow key steps
187
+ * the value.
188
+ *
189
+ * A controlled input whose `value` is replaced drops the caret at the end,
190
+ * so without this the second press of a repeated step always acts on the
191
+ * last digit. With it, the digit under the caret stays under the caret and
192
+ * a column can be held while stepping.
193
+ *
194
+ * The position is measured from the decimal point rather than from either
195
+ * end, so it survives the number growing or shrinking: the caret between
196
+ * `9` and `.9` is still between `10` and `.0`.
197
+ *
198
+ * It only restores the caret. Which digit it sits on does not change the
199
+ * size of the step — that is `keyboard`'s to say.
200
+ *
201
+ * @default false
202
+ */
203
+ keepCaretOnStep?: boolean
204
+ /**
205
+ * Commit and leave `InputField` when Enter is pressed. Enter commits either
206
+ * way.
207
+ * @default true
208
+ */
209
+ blurOnEnter?: boolean
210
+
211
+ /**
212
+ * Make the input unchangeable and remove it from the tab order.
213
+ * The parts carry `data-disabled` while it is set.
104
214
  */
105
215
  disabled?: boolean
106
216
  /**
107
217
  * Make the value unchangeable.
108
- * aria-readonly property is also applied.
218
+ * The parts carry `data-readonly` while it is set.
109
219
  */
110
220
  readonly?: boolean
111
221
 
112
222
  className?: string
113
223
  style?: CSSProperties
224
+ /**
225
+ * Called with the new value. While the user types, it is called for every
226
+ * entry that reads as a number, unclamped; committing the entry calls it
227
+ * again if clamping changes the value.
228
+ */
114
229
  onChange?: (value: number) => void
115
230
 
116
231
  /**
@@ -134,10 +249,24 @@ export interface NumberInputMethods {
134
249
  blur: () => void
135
250
  }
136
251
 
252
+ /**
253
+ * A number at the start of the text, and nothing read after it. A half-typed
254
+ * entry still yields the number in front of it, but text with no number is
255
+ * `NaN` rather than 0, so that "unreadable" and "the user typed 0" stay apart.
256
+ */
257
+ const LEADING_NUMBER = /^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/
258
+
259
+ const defaultFormat = (value: number) => String(value)
260
+
261
+ const defaultParse = (text: string) => {
262
+ const match = text.match(LEADING_NUMBER)
263
+ return match ? Number(match[1]) : NaN
264
+ }
265
+
137
266
  type Props = NumberInputProps &
138
267
  Omit<ComponentPropsWithoutRef<'div'>, keyof NumberInputProps>
139
268
 
140
- export const Root = forwardRef<NumberInputMethods, Props>(
269
+ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
141
270
  (
142
271
  {
143
272
  value,
@@ -145,14 +274,18 @@ export const Root = forwardRef<NumberInputMethods, Props>(
145
274
  max,
146
275
  step = 1,
147
276
  scale = linearScale,
148
- units,
149
- digit,
150
277
  format: formatProp,
151
278
  parse: parseProp,
152
279
  clampValue = true,
153
- wheel = ['raw', 1],
154
- keyboard = ['raw', 1],
280
+ wheel = DEFAULT_WHEEL_OPTIONS,
281
+ keyboard = DEFAULT_KEYBOARD_OPTIONS,
155
282
  drag = 1,
283
+ dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
284
+ pointerLock = false,
285
+ selectOnFocus = 'none',
286
+ unformatOnFocus = false,
287
+ keepCaretOnStep = false,
288
+ blurOnEnter = true,
156
289
  disabled = false,
157
290
  readonly = false,
158
291
  className,
@@ -170,20 +303,15 @@ export const Root = forwardRef<NumberInputMethods, Props>(
170
303
  * but the half-finished entry that has no value to be derived from yet.
171
304
  */
172
305
  const [draft, setDraft] = useState<string | null>(null)
306
+ const inactive = disabled || readonly
173
307
 
174
308
  // --- interpret props ---
175
- const format = useCallback(
176
- (v: number) =>
177
- formatProp ? formatProp(v) : formatValue(v, units, digit),
178
- [formatProp, units, digit],
179
- )
180
- const parse = useCallback(
181
- (t: string) => (parseProp ? parseProp(t) : parseValue(t, units)),
182
- [parseProp, units],
183
- )
309
+ const format = formatProp ?? defaultFormat
310
+ const parse = parseProp ?? defaultParse
184
311
 
185
- // An unbounded end, and a range the caller opted out of enforcing, both
186
- // become the widest range the value pipeline can express.
312
+ // Normalized input needs a finite span even when an end is unbounded or
313
+ // the caller opted out of clamping. Safe integers provide one without
314
+ // overflowing the span calculation used by a scale.
187
315
  const range: ValueRange = useMemo(
188
316
  () => ({
189
317
  min: (clampValue ? min : undefined) ?? Number.MIN_SAFE_INTEGER,
@@ -194,53 +322,108 @@ export const Root = forwardRef<NumberInputMethods, Props>(
194
322
  [clampValue, min, max, step, scale],
195
323
  )
196
324
 
325
+ // Raw input does not need a finite span for normalization, so its open
326
+ // ends can cover every finite JavaScript number instead of stopping at the
327
+ // safe-integer range.
328
+ const rawRange: ValueRange = useMemo(
329
+ () => ({
330
+ min: (clampValue ? min : undefined) ?? -Number.MAX_VALUE,
331
+ max: (clampValue ? max : undefined) ?? Number.MAX_VALUE,
332
+ step,
333
+ scale,
334
+ }),
335
+ [clampValue, min, max, step, scale],
336
+ )
337
+
338
+ // The pipeline range stands in the safe-integer range when an end is
339
+ // open, and there is no travel to sample across that. Probing is skipped
340
+ // rather than run against a range nobody set.
341
+ const probeRange = useMemo(
342
+ () =>
343
+ min !== undefined && max !== undefined && min < max
344
+ ? { min, max, step, scale }
345
+ : null,
346
+ [min, max, step, scale],
347
+ )
348
+
349
+ useCheckSteps({
350
+ component: 'NumberInput',
351
+ range: probeRange,
352
+ keyboard,
353
+ wheel,
354
+ format,
355
+ })
356
+
197
357
  const text = draft ?? format(value)
198
- const editing = draft != null
358
+ const editing = draft !== null
199
359
  const outOfRange =
200
360
  !editing &&
201
- ((min != undefined && value < min) || (max != undefined && value > max))
361
+ ((min !== undefined && value < min) || (max !== undefined && value > max))
202
362
 
203
363
  // --- internal functions ---
204
364
  const handleDraft = useCallback(
205
365
  (next: string) => {
206
- if (readonly) return
366
+ if (inactive) return
207
367
  setDraft(next)
208
368
  // Deliberately unclamped: clamping here would make "1500" impossible
209
369
  // to type into an input whose max is 100.
210
370
  const parsed = parse(next)
211
371
  if (Number.isFinite(parsed)) onChange?.(parsed)
212
372
  },
213
- [readonly, parse, onChange],
373
+ [inactive, parse, onChange],
214
374
  )
215
375
 
216
376
  const changeValue = useCallback(
217
377
  (next: number) => {
218
- if (readonly) return
378
+ if (inactive) return
219
379
  setDraft(null)
220
- if (next != value) onChange?.(next)
380
+ if (next !== value) onChange?.(next)
221
381
  },
222
- [readonly, value, onChange],
382
+ [inactive, value, onChange],
223
383
  )
224
384
 
225
385
  const commitDraft = useCallback(() => {
226
- if (draft == null || readonly) return
227
- // `range` is already the widest possible range when clampValue is off.
228
- changeValue(clamp(parse(draft), range.min, range.max))
229
- }, [draft, readonly, parse, range, changeValue])
386
+ if (draft === null || inactive) return
387
+ const parsed = parse(draft)
388
+ // Text with no number in it is not a value. Dropping the draft puts the
389
+ // input back to what it was showing, rather than committing a zero the
390
+ // user never typed.
391
+ if (!Number.isFinite(parsed)) {
392
+ setDraft(null)
393
+ return
394
+ }
395
+ let committed = parsed
396
+ if (clampValue && min !== undefined) committed = Math.max(committed, min)
397
+ if (clampValue && max !== undefined) committed = Math.min(committed, max)
398
+ changeValue(committed)
399
+ }, [draft, inactive, parse, clampValue, min, max, changeValue])
230
400
 
231
401
  const nudge = useCallback(
232
- (direction: number, option: InputEventOption) => {
233
- changeValue(applyDelta(value, direction, option, range))
402
+ (
403
+ direction: number,
404
+ option: ModifierValue<InputEventOption>,
405
+ modifiers?: ModifierState,
406
+ ) => {
407
+ const [mode] = selectInputEvent(option, modifiers).option
408
+ changeValue(
409
+ applyDelta(
410
+ value,
411
+ direction,
412
+ option,
413
+ mode === 'raw' ? rawRange : range,
414
+ modifiers,
415
+ ),
416
+ )
234
417
  },
235
- [changeValue, value, range],
418
+ [changeValue, value, rawRange, range],
236
419
  )
237
420
 
238
421
  // --- hooks ---
239
422
  const wheelRefCallback = useWheel<HTMLDivElement>(
240
423
  (event) => {
241
- if (!wheel || readonly || event.deltaY == 0) return
424
+ if (!wheel || inactive || event.deltaY === 0) return
242
425
  event.preventDefault()
243
- nudge(-Math.sign(event.deltaY), wheel)
426
+ nudge(-Math.sign(event.deltaY), wheel, event)
244
427
  },
245
428
  { requireFocus: true },
246
429
  )
@@ -256,12 +439,19 @@ export const Root = forwardRef<NumberInputMethods, Props>(
256
439
  readonly,
257
440
  clampValue,
258
441
  range,
442
+ rawRange,
259
443
  keyboard,
260
444
  text,
261
445
  editing,
262
446
  outOfRange,
263
- atMin: clampValue && min != undefined && value <= min,
264
- atMax: clampValue && max != undefined && value >= max,
447
+ dragSensitivity,
448
+ pointerLock,
449
+ selectOnFocus,
450
+ unformatOnFocus,
451
+ keepCaretOnStep,
452
+ blurOnEnter,
453
+ atMin: clampValue && min !== undefined && value <= min,
454
+ atMax: clampValue && max !== undefined && value >= max,
265
455
  drag,
266
456
  format,
267
457
  parse,
@@ -281,11 +471,18 @@ export const Root = forwardRef<NumberInputMethods, Props>(
281
471
  readonly,
282
472
  clampValue,
283
473
  range,
474
+ rawRange,
284
475
  keyboard,
285
476
  text,
286
477
  editing,
287
478
  outOfRange,
288
479
  drag,
480
+ dragSensitivity,
481
+ pointerLock,
482
+ selectOnFocus,
483
+ unformatOnFocus,
484
+ keepCaretOnStep,
485
+ blurOnEnter,
289
486
  format,
290
487
  parse,
291
488
  handleDraft,
@@ -298,21 +495,21 @@ export const Root = forwardRef<NumberInputMethods, Props>(
298
495
  useImperativeHandle(forwardedRef, () => {
299
496
  return {
300
497
  focus() {
301
- inputRef.current?.focus()
498
+ if (!disabled) inputRef.current?.focus()
302
499
  },
303
500
  blur() {
304
501
  inputRef.current?.blur()
305
502
  },
306
503
  }
307
- }, [])
504
+ }, [disabled])
308
505
 
309
506
  return (
310
507
  <NumberInputProvider value={context}>
311
508
  <div
312
509
  ref={wheelRefCallback}
313
- className={clsx('tremolo-number-input', className)}
314
- aria-disabled={disabled}
315
- aria-readonly={readonly}
510
+ className={className}
511
+ data-disabled={disabled ? '' : undefined}
512
+ data-readonly={readonly ? '' : undefined}
316
513
  style={style}
317
514
  {...props}
318
515
  >
@@ -334,8 +531,8 @@ export const NumberInput = {
334
531
  DecrementStepper,
335
532
  }
336
533
 
337
- export { useNumberInputContext } from './context'
338
- export { type NumberInputFieldProps } from './InputField'
339
- export { type StepperProps } from './Stepper'
340
- export { type IncrementStepperProps } from './IncrementStepper'
341
- export { type DecrementStepperProps } from './DecrementStepper'
534
+ export { useNumberInputContext, type NumberInputContextValue } from './context'
535
+ export { type NumberInputInputFieldProps } from './InputField'
536
+ export { type NumberInputStepperProps } from './Stepper'
537
+ export { type NumberInputIncrementStepperProps } from './IncrementStepper'
538
+ export { type NumberInputDecrementStepperProps } from './DecrementStepper'