@tremolo-ui/react 0.6.0 → 0.8.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 (57) hide show
  1. package/dist/index.cjs +522 -997
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +601 -329
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +601 -329
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +487 -953
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/src/components/DropZone/index.tsx +101 -0
  11. package/src/components/FileInput/Trigger.tsx +39 -0
  12. package/src/components/FileInput/context.tsx +24 -0
  13. package/src/components/FileInput/index.tsx +139 -0
  14. package/src/components/Knob/ActiveLine.tsx +5 -3
  15. package/src/components/Knob/InactiveLine.tsx +6 -4
  16. package/src/components/Knob/SVGRoot.tsx +3 -3
  17. package/src/components/Knob/Thumb.tsx +3 -2
  18. package/src/components/Knob/context.tsx +3 -74
  19. package/src/components/Knob/index.tsx +27 -21
  20. package/src/components/NumberInput/DecrementStepper.tsx +2 -13
  21. package/src/components/NumberInput/IncrementStepper.tsx +2 -14
  22. package/src/components/NumberInput/InputField.tsx +17 -38
  23. package/src/components/NumberInput/Stepper.tsx +44 -107
  24. package/src/components/NumberInput/StepperButton.tsx +16 -12
  25. package/src/components/NumberInput/context.tsx +8 -8
  26. package/src/components/NumberInput/index.tsx +41 -81
  27. package/src/components/Piano/index.tsx +39 -149
  28. package/src/components/PointsEditor/Background.tsx +2 -8
  29. package/src/components/PointsEditor/Container.tsx +17 -34
  30. package/src/components/PointsEditor/Point.tsx +41 -86
  31. package/src/components/PointsEditor/SelectionBox.tsx +2 -8
  32. package/src/components/PointsEditor/context.tsx +10 -61
  33. package/src/components/PointsEditor/index.tsx +31 -315
  34. package/src/components/Slider/Marks.tsx +8 -13
  35. package/src/components/Slider/MarksOption.tsx +11 -17
  36. package/src/components/Slider/Thumb.tsx +9 -15
  37. package/src/components/Slider/Track.tsx +7 -13
  38. package/src/components/Slider/index.tsx +33 -39
  39. package/src/components/XYPad/Area.tsx +26 -53
  40. package/src/components/XYPad/Thumb.tsx +8 -12
  41. package/src/components/XYPad/context.tsx +1 -3
  42. package/src/components/XYPad/index.tsx +35 -50
  43. package/src/components/_util/VisuallyHiddenRangeInput.tsx +3 -15
  44. package/src/css-variables.ts +23 -0
  45. package/src/hooks/_internal/useCheckSteps.ts +9 -147
  46. package/src/hooks/useDrag.ts +13 -4
  47. package/src/hooks/useDragValue.ts +17 -2
  48. package/src/hooks/useDropZone.ts +78 -0
  49. package/src/hooks/useLongPress.ts +35 -51
  50. package/src/hooks/useMIDIAccess.ts +0 -8
  51. package/src/hooks/useMIDIInput.ts +0 -3
  52. package/src/hooks/useWheel.ts +4 -1
  53. package/src/index.ts +14 -36
  54. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  55. package/src/components/Slider/type.ts +0 -44
  56. package/src/components/_util/css-length.ts +0 -13
  57. package/src/input-event.ts +0 -32
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  ComponentPropsWithoutRef,
3
- CSSProperties,
4
3
  forwardRef,
5
4
  useEffect,
6
5
  useLayoutEffect,
@@ -8,39 +7,21 @@ import {
8
7
  useState,
9
8
  } from 'react'
10
9
 
10
+ import {
11
+ caretAtDecimalOffset,
12
+ caretDecimalOffset,
13
+ numberSpan,
14
+ } from '@tremolo-ui/dom'
15
+
11
16
  import { useComposedRefs } from '../../compose-refs'
12
17
 
13
18
  import { useNumberInputContext } from './context'
14
19
 
15
- export interface NumberInputInputFieldProps {
16
- className?: string
17
- style?: CSSProperties
18
- }
19
-
20
- type Props = NumberInputInputFieldProps &
21
- Omit<
22
- ComponentPropsWithoutRef<'input'>,
23
- keyof NumberInputInputFieldProps | 'type' | 'value' | 'defaultValue'
24
- >
25
-
26
- /** The leading number of the displayed text, whatever the format put around it. */
27
- const NUMBER_PREFIX = /^\s*-?[\d.,]*/
28
-
29
- const numberEnd = (text: string) =>
30
- text.match(NUMBER_PREFIX)?.[0].length ?? text.length
31
-
32
- /**
33
- * The index the caret is measured against: the decimal point, or where one
34
- * would go if the number has none.
35
- *
36
- * Measuring from an end instead would slide the caret across a digit whenever
37
- * the number changed length — `9.9` to `10.0` gains a character in front, `10`
38
- * to `9` loses one — which is exactly what stepping does.
39
- */
40
- const decimalAnchor = (text: string) => {
41
- const dot = text.indexOf('.')
42
- return dot === -1 ? numberEnd(text) : dot
43
- }
20
+ // The value belongs to `NumberInput.Root`, and the field is always text.
21
+ type Props = Omit<
22
+ ComponentPropsWithoutRef<'input'>,
23
+ 'type' | 'value' | 'defaultValue'
24
+ >
44
25
 
45
26
  /**
46
27
  * The text field of a `NumberInput`, and the only place the value can be typed.
@@ -53,7 +34,7 @@ const decimalAnchor = (text: string) => {
53
34
  */
54
35
  export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
55
36
  function InputField(
56
- { className, style, onFocus, onBlur, onKeyDown, ...props },
37
+ { className, style, onChange, onFocus, onBlur, onKeyDown, ...props },
57
38
  forwardedRef,
58
39
  ) {
59
40
  const {
@@ -93,10 +74,8 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
93
74
  if (selectOnFocus === 'all') {
94
75
  input.setSelectionRange(0, input.value.length)
95
76
  } else {
96
- input.setSelectionRange(
97
- 0,
98
- input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
99
- )
77
+ const span = numberSpan(input.value)
78
+ if (span) input.setSelectionRange(span.start, span.end)
100
79
  }
101
80
  // Deliberately not re-run as the text changes: that would drag the
102
81
  // selection back over what the user is typing.
@@ -120,8 +99,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
120
99
  // caret has not moved either, since the key press was prevented.
121
100
  if (!input || input.value === pending.from) return
122
101
 
123
- const place = decimalAnchor(input.value) + pending.offset
124
- const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
102
+ const caret = caretAtDecimalOffset(input.value, pending.offset)
125
103
  input.setSelectionRange(caret, caret)
126
104
  })
127
105
 
@@ -160,6 +138,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
160
138
  // stepped one no longer means anything.
161
139
  caretAfterStep.current = null
162
140
  setDraft(event.currentTarget.value)
141
+ onChange?.(event)
163
142
  }}
164
143
  onFocus={(event) => {
165
144
  setFocused(true)
@@ -191,7 +170,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
191
170
  const input = event.currentTarget
192
171
  if (keepCaretOnStep && input.selectionStart !== null) {
193
172
  caretAfterStep.current = {
194
- offset: input.selectionStart - decimalAnchor(input.value),
173
+ offset: caretDecimalOffset(input.value, input.selectionStart),
195
174
  from: input.value,
196
175
  }
197
176
  }
@@ -1,37 +1,24 @@
1
1
  import {
2
2
  ComponentPropsWithoutRef,
3
- CSSProperties,
4
3
  forwardRef,
5
- ReactNode,
4
+ useEffect,
6
5
  useMemo,
7
6
  useRef,
7
+ useState,
8
8
  } from 'react'
9
9
 
10
- import {
11
- applyDelta,
12
- mapModifier,
13
- selectModifier,
14
- type InputEventOption,
15
- } from '@tremolo-ui/functions'
10
+ import { createStepperDrag, type StepperDragInstance } from '@tremolo-ui/dom'
16
11
 
17
12
  import { useComposedRefs } from '../../compose-refs'
18
- import { useDrag } from '../../hooks/useDrag'
19
13
 
20
14
  import { StepperProvider, useNumberInputContext } from './context'
21
15
 
22
- export interface NumberInputStepperProps {
23
- className?: string
24
- style?: CSSProperties
25
- /** `<NumberInput.IncrementStepper />` and `<NumberInput.DecrementStepper />` go here. */
26
- children?: ReactNode
27
- }
28
-
29
- type Props = NumberInputStepperProps &
30
- Omit<ComponentPropsWithoutRef<'div'>, keyof NumberInputStepperProps>
16
+ type Props = ComponentPropsWithoutRef<'div'>
31
17
 
32
18
  /**
33
19
  * The area the steppers sit in, and a drag handle in its own right: dragging it
34
- * up and down moves the value one `step` every `drag` pixels.
20
+ * up and down moves the value one `step` every `drag` pixels. The counting is
21
+ * `createStepperDrag` in the core.
35
22
  *
36
23
  * The drag lives here rather than on `InputField` because `createDrag` turns
37
24
  * off text selection on whatever element it is attached to.
@@ -40,7 +27,6 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
40
27
  function Stepper({ className, style, children, ...props }, forwardedRef) {
41
28
  const {
42
29
  value,
43
- step,
44
30
  disabled,
45
31
  readonly,
46
32
  drag,
@@ -49,100 +35,51 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
49
35
  rawRange,
50
36
  changeValue,
51
37
  } = useNumberInputContext()
52
- const inactive = disabled || readonly
53
-
54
- /**
55
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
56
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
57
- * the modifier map over rather than resolving it here is what keeps `step`
58
- * out of the pipeline for a modifier entry — naming one is a request to move
59
- * off the grid.
60
- */
61
- const dragOptions = useMemo(
62
- () =>
63
- mapModifier(dragSensitivity, (factor): InputEventOption => [
64
- 'raw',
65
- step * factor,
66
- ]),
67
- [dragSensitivity, step],
68
- )
38
+ // Only attached while it can do something: `createDrag` puts
39
+ // `touch-action: none` on the element, and a stepper that cannot be
40
+ // dragged has no reason to stop the page scrolling under a finger.
41
+ const enabled = drag !== null && !disabled && !readonly
69
42
 
70
- /**
71
- * Set once the drag has actually moved the value, so that the press-and-hold
72
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
73
- * because it is read from inside the repeat rather than rendered.
74
- */
75
- const draggingRef = useRef(false)
76
- /**
77
- * Where the drag started, taken on the first move rather than on pointer
78
- * down: the steppers act on pointer down, so by then the value may already
79
- * have been nudged once, and the drag should carry on from there.
80
- */
81
- const originRef = useRef<{ y: number; value: number } | null>(null)
82
- /**
83
- * Which modifier the drag is currently counting at, and where the previous
84
- * event was — a key produces no pointer event of its own, so a change is
85
- * only seen on the next move and has to be dated back to the one before it.
86
- */
87
- const factorRef = useRef<number>(1)
88
- const previousYRef = useRef(0)
43
+ // See useDrag for why the node is held in state rather than a ref.
44
+ const [node, setNode] = useState<HTMLDivElement | null>(null)
45
+ const instanceRef = useRef<StepperDragInstance | null>(null)
46
+ const latest = useRef({ value, changeValue })
47
+ const options = {
48
+ range: rawRange,
49
+ pixels: drag ?? 1,
50
+ sensitivity: dragSensitivity,
51
+ pointerLock,
52
+ }
53
+ const optionsRef = useRef(options)
89
54
 
90
- const dragRefCallback = useDrag<HTMLDivElement>({
91
- threshold: 1,
92
- cursor: inactive ? undefined : 'ns-resize',
93
- pointerLock: inactive ? false : pointerLock,
94
- onDragStart: (state) => {
95
- originRef.current = null
96
- draggingRef.current = false
97
- factorRef.current = selectModifier(dragSensitivity, state.event).value
98
- },
99
- onDrag: (_x, y, _dx, _dy, state) => {
100
- if (inactive || drag === null) return
101
- if (!originRef.current) {
102
- originRef.current = { y, value }
103
- previousYRef.current = y
104
- return
105
- }
55
+ // Runs after every render.
56
+ useEffect(() => {
57
+ latest.current = { value, changeValue }
58
+ optionsRef.current = options
59
+ instanceRef.current?.update(options)
60
+ })
106
61
 
107
- // Pressing or releasing the key mid-drag must not move the value, so the
108
- // travel so far is folded into the origin and measuring starts again
109
- // from the previous event: that event's own distance belongs to the new
110
- // sensitivity.
111
- const factor = selectModifier(dragSensitivity, state.event).value
112
- if (factor !== factorRef.current) {
113
- originRef.current = { y: previousYRef.current, value }
114
- factorRef.current = factor
115
- }
116
- previousYRef.current = y
62
+ useEffect(() => {
63
+ if (!node || !enabled) return
64
+ const instance = createStepperDrag(node, {
65
+ ...optionsRef.current,
66
+ getValue: () => latest.current.value,
67
+ onChange: (next) => latest.current.changeValue(next),
68
+ })
69
+ instanceRef.current = instance
70
+ return () => {
71
+ instanceRef.current = null
72
+ instance.destroy()
73
+ }
74
+ }, [node, enabled])
117
75
 
118
- // Dragging up raises the value, as on a knob.
119
- const steps = Math.round(-(y - originRef.current.y) / drag)
120
- if (steps === 0) return
121
- draggingRef.current = true
122
- // The same pipeline the wheel and the arrow keys use. Counting from where
123
- // the drag started keeps it from accumulating a rounding error.
124
- changeValue(
125
- applyDelta(
126
- originRef.current.value,
127
- steps,
128
- dragOptions,
129
- rawRange,
130
- state.event,
131
- ),
132
- )
133
- },
134
- onDragEnd: () => {
135
- draggingRef.current = false
136
- },
137
- })
76
+ const composedRef = useComposedRefs<HTMLDivElement>(forwardedRef, setNode)
138
77
 
139
- const composedRef = useComposedRefs<HTMLDivElement>(
140
- forwardedRef,
141
- drag !== null && !inactive ? dragRefCallback : undefined,
78
+ const context = useMemo(
79
+ () => ({ moved: () => instanceRef.current?.moved() ?? false }),
80
+ [],
142
81
  )
143
82
 
144
- const context = useMemo(() => ({ draggingRef }), [])
145
-
146
83
  return (
147
84
  <StepperProvider value={context}>
148
85
  <div ref={composedRef} className={className} style={style} {...props}>
@@ -1,18 +1,23 @@
1
- import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'
1
+ import { ComponentPropsWithoutRef, ReactNode, CSSProperties } from 'react'
2
2
 
3
3
  import { useLongPress } from '../../hooks/useLongPress'
4
4
 
5
5
  import { useNumberInputContext, useStepperContext } from './context'
6
6
 
7
- export interface StepperButtonProps {
8
- className?: string
9
- style?: CSSProperties
10
- /** Replaces the default arrow. Size it with the `--stepper-icon-size` variable. */
11
- children?: ReactNode
7
+ import type { CSSVariables } from '../../css-variables'
8
+
9
+ /**
10
+ * `--stepper-icon-size` is read by the default arrow inside, and set on the
11
+ * button so that it inherits there.
12
+ */
13
+ export type StepperButtonProps = Omit<
14
+ ComponentPropsWithoutRef<'div'>,
15
+ 'style'
16
+ > & {
17
+ style?: CSSProperties & CSSVariables<'stepper-icon-size'>
12
18
  }
13
19
 
14
- type Props = StepperButtonProps &
15
- Omit<ComponentPropsWithoutRef<'div'>, keyof StepperButtonProps>
20
+ type Props = StepperButtonProps
16
21
 
17
22
  /**
18
23
  * Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
@@ -36,13 +41,12 @@ export function StepperButton({
36
41
  useNumberInputContext()
37
42
  const stepper = useStepperContext()
38
43
 
39
- // Not memoized: the callback reads `draggingRef.current`, which the compiler
40
- // cannot line up with a manual dependency list. `useLongPress` only ever
41
- // calls it through a ref, so a fresh identity per render costs nothing.
44
+ // Not memoized: `useLongPress` only ever calls it through a ref, so a fresh
45
+ // identity per render costs nothing.
42
46
  const press = useLongPress(() => {
43
47
  // Once the pointer has actually travelled, the drag on `Stepper` owns the
44
48
  // value; repeating on top of it would move it twice.
45
- if (stepper?.draggingRef.current) return
49
+ if (stepper?.moved()) return
46
50
  nudge(direction, ['raw', step])
47
51
  })
48
52
 
@@ -1,12 +1,11 @@
1
1
  import { createContext, RefObject, useContext } from 'react'
2
2
 
3
- import type {
4
- InputEventOption,
5
- ModifierState,
6
- ModifierValue,
7
- Scale,
8
- ValueRange,
9
- } 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'
10
9
 
11
10
  export type NumberInputContextValue = {
12
11
  value: number
@@ -104,7 +103,8 @@ export function useNumberInputContext<T>(
104
103
 
105
104
  /** Set by `Stepper` once a drag has actually moved, so the steppers stand down. */
106
105
  export type StepperContextValue = {
107
- draggingRef: RefObject<boolean>
106
+ /** Whether the drag in progress has moved the value. */
107
+ moved: () => boolean
108
108
  }
109
109
 
110
110
  const StepperContext =
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  ComponentPropsWithoutRef,
3
- CSSProperties,
4
3
  forwardRef,
5
4
  ReactNode,
6
5
  useCallback,
@@ -11,23 +10,23 @@ import {
11
10
  } from 'react'
12
11
 
13
12
  import {
14
- applyDelta,
13
+ DEFAULT_DRAG_SENSITIVITY,
14
+ DEFAULT_KEYBOARD_OPTIONS,
15
+ DEFAULT_WHEEL_OPTIONS,
16
+ commitNumberInputText,
15
17
  InputEventOption,
16
18
  ModifierState,
17
19
  type ModifierValue,
18
- linearScale,
19
- selectInputEvent,
20
- type Scale,
21
- type ValueRange,
22
- } from '@tremolo-ui/functions'
20
+ numberInputBounds,
21
+ numberInputRanges,
22
+ nudgeNumberInput,
23
+ parseNumberText,
24
+ wheelDirection,
25
+ } from '@tremolo-ui/dom'
26
+ import { linearScale, type Scale } from '@tremolo-ui/functions'
23
27
 
24
28
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
25
29
  import { useWheel } from '../../hooks/useWheel'
26
- import {
27
- DEFAULT_DRAG_SENSITIVITY,
28
- DEFAULT_KEYBOARD_OPTIONS,
29
- DEFAULT_WHEEL_OPTIONS,
30
- } from '../../input-event'
31
30
 
32
31
  import { NumberInputProvider } from './context'
33
32
  import { DecrementStepper } from './DecrementStepper'
@@ -86,6 +85,9 @@ export interface NumberInputProps {
86
85
  * Read a value back out of the text. Has to undo `format`.
87
86
  *
88
87
  * Text with no number in it reads as `NaN`, which leaves the value alone.
88
+ * The default, `parseNumberText` from `@tremolo-ui/dom`, reads the number
89
+ * with the unit around it left out, and is `NaN` for one it cannot read
90
+ * whole: `1,000`, `1:30`, or a number with a unit in front, such as `L 30`.
89
91
  */
90
92
  parse?: (text: string) => number
91
93
 
@@ -163,7 +165,7 @@ export interface NumberInputProps {
163
165
 
164
166
  /**
165
167
  * Select the text when `InputField` takes focus: `'all'` selects all of it,
166
- * `'number'` only the leading number, leaving whatever the format appended,
168
+ * `'number'` only the number, leaving the unit the format put around it,
167
169
  * and `'none'` leaves the caret where the click put it.
168
170
  * @default 'none'
169
171
  */
@@ -219,8 +221,6 @@ export interface NumberInputProps {
219
221
  */
220
222
  readonly?: boolean
221
223
 
222
- className?: string
223
- style?: CSSProperties
224
224
  /**
225
225
  * Called with the new value. While the user types, it is called for every
226
226
  * entry that reads as a number, unclamped; committing the entry calls it
@@ -249,20 +249,8 @@ export interface NumberInputMethods {
249
249
  blur: () => void
250
250
  }
251
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
252
  const defaultFormat = (value: number) => String(value)
260
253
 
261
- const defaultParse = (text: string) => {
262
- const match = text.match(LEADING_NUMBER)
263
- return match ? Number(match[1]) : NaN
264
- }
265
-
266
254
  type Props = NumberInputProps &
267
255
  Omit<ComponentPropsWithoutRef<'div'>, keyof NumberInputProps>
268
256
 
@@ -307,32 +295,15 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
307
295
 
308
296
  // --- interpret props ---
309
297
  const format = formatProp ?? defaultFormat
310
- const parse = parseProp ?? defaultParse
298
+ const parse = parseProp ?? parseNumberText
311
299
 
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.
315
- const range: ValueRange = useMemo(
316
- () => ({
317
- min: (clampValue ? min : undefined) ?? Number.MIN_SAFE_INTEGER,
318
- max: (clampValue ? max : undefined) ?? Number.MAX_SAFE_INTEGER,
319
- step,
320
- scale,
321
- }),
300
+ const { normalized: range, raw: rawRange } = useMemo(
301
+ () => numberInputRanges({ min, max, step, scale, clampValue }),
322
302
  [clampValue, min, max, step, scale],
323
303
  )
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],
304
+ const ranges = useMemo(
305
+ () => ({ normalized: range, raw: rawRange }),
306
+ [range, rawRange],
336
307
  )
337
308
 
338
309
  // The pipeline range stands in the safe-integer range when an end is
@@ -356,9 +327,9 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
356
327
 
357
328
  const text = draft ?? format(value)
358
329
  const editing = draft !== null
359
- const outOfRange =
360
- !editing &&
361
- ((min !== undefined && value < min) || (max !== undefined && value > max))
330
+ const bounds = numberInputBounds(value, { min, max, clampValue })
331
+ const { atMin, atMax } = bounds
332
+ const outOfRange = !editing && bounds.outOfRange
362
333
 
363
334
  // --- internal functions ---
364
335
  const handleDraft = useCallback(
@@ -384,18 +355,15 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
384
355
 
385
356
  const commitDraft = useCallback(() => {
386
357
  if (draft === null || inactive) return
387
- const parsed = parse(draft)
358
+ const committed = commitNumberInputText(draft, parse, {
359
+ min,
360
+ max,
361
+ clampValue,
362
+ })
388
363
  // 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)
364
+ // input back to what it was showing.
365
+ if (committed === null) setDraft(null)
366
+ else changeValue(committed)
399
367
  }, [draft, inactive, parse, clampValue, min, max, changeValue])
400
368
 
401
369
  const nudge = useCallback(
@@ -404,26 +372,20 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
404
372
  option: ModifierValue<InputEventOption>,
405
373
  modifiers?: ModifierState,
406
374
  ) => {
407
- const [mode] = selectInputEvent(option, modifiers).option
408
375
  changeValue(
409
- applyDelta(
410
- value,
411
- direction,
412
- option,
413
- mode === 'raw' ? rawRange : range,
414
- modifiers,
415
- ),
376
+ nudgeNumberInput(value, direction, option, ranges, modifiers),
416
377
  )
417
378
  },
418
- [changeValue, value, rawRange, range],
379
+ [changeValue, value, ranges],
419
380
  )
420
381
 
421
382
  // --- hooks ---
422
383
  const wheelRefCallback = useWheel<HTMLDivElement>(
423
384
  (event) => {
424
- if (!wheel || inactive || event.deltaY === 0) return
385
+ const direction = wheelDirection(event)
386
+ if (!wheel || inactive || direction === null) return
425
387
  event.preventDefault()
426
- nudge(-Math.sign(event.deltaY), wheel, event)
388
+ nudge(direction, wheel, event)
427
389
  },
428
390
  { requireFocus: true },
429
391
  )
@@ -450,8 +412,8 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
450
412
  unformatOnFocus,
451
413
  keepCaretOnStep,
452
414
  blurOnEnter,
453
- atMin: clampValue && min !== undefined && value <= min,
454
- atMax: clampValue && max !== undefined && value >= max,
415
+ atMin,
416
+ atMax,
455
417
  drag,
456
418
  format,
457
419
  parse,
@@ -476,6 +438,8 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
476
438
  text,
477
439
  editing,
478
440
  outOfRange,
441
+ atMin,
442
+ atMax,
479
443
  drag,
480
444
  dragSensitivity,
481
445
  pointerLock,
@@ -532,7 +496,3 @@ export const NumberInput = {
532
496
  }
533
497
 
534
498
  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'