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