@tremolo-ui/react 0.7.0 → 0.9.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 (55) hide show
  1. package/dist/index.cjs +666 -1171
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +316 -315
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +316 -315
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +643 -1123
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/components/AnimationCanvas/index.tsx +4 -18
  11. package/src/components/DropZone/index.tsx +2 -1
  12. package/src/components/FileInput/index.tsx +11 -13
  13. package/src/components/Knob/ActiveLine.tsx +5 -3
  14. package/src/components/Knob/InactiveLine.tsx +6 -4
  15. package/src/components/Knob/SVGRoot.tsx +3 -3
  16. package/src/components/Knob/Thumb.tsx +21 -20
  17. package/src/components/Knob/context.tsx +3 -74
  18. package/src/components/Knob/index.tsx +99 -52
  19. package/src/components/NumberInput/InputField.tsx +20 -33
  20. package/src/components/NumberInput/Stepper.tsx +76 -100
  21. package/src/components/NumberInput/StepperButton.tsx +15 -8
  22. package/src/components/NumberInput/context.tsx +26 -3
  23. package/src/components/NumberInput/index.tsx +175 -91
  24. package/src/components/Piano/index.tsx +54 -144
  25. package/src/components/PointsEditor/Container.tsx +15 -26
  26. package/src/components/PointsEditor/Point.tsx +94 -110
  27. package/src/components/PointsEditor/context.tsx +8 -60
  28. package/src/components/PointsEditor/index.tsx +41 -324
  29. package/src/components/Slider/Marks.tsx +5 -6
  30. package/src/components/Slider/MarksOption.tsx +9 -19
  31. package/src/components/Slider/Thumb.tsx +20 -11
  32. package/src/components/Slider/Track.tsx +16 -13
  33. package/src/components/Slider/context.tsx +3 -3
  34. package/src/components/Slider/index.tsx +110 -72
  35. package/src/components/XYPad/Area.tsx +27 -49
  36. package/src/components/XYPad/Thumb.tsx +20 -8
  37. package/src/components/XYPad/context.tsx +2 -4
  38. package/src/components/XYPad/index.tsx +102 -83
  39. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  40. package/src/hooks/_internal/useChangeGesture.ts +93 -0
  41. package/src/hooks/_internal/useCheckSteps.ts +9 -146
  42. package/src/hooks/useAnimationFrame.ts +37 -15
  43. package/src/hooks/useEventListener.ts +93 -22
  44. package/src/hooks/useLongPress.ts +35 -51
  45. package/src/hooks/useMIDIAccess.ts +0 -8
  46. package/src/hooks/useMIDIInput.ts +0 -3
  47. package/src/hooks/useWheel.ts +4 -4
  48. package/src/index.ts +11 -32
  49. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  50. package/src/components/Slider/type.ts +0 -46
  51. package/src/components/_util/css-length.ts +0 -13
  52. package/src/components/_util/decimal-digits.ts +0 -23
  53. package/src/components/_util/visually-hidden.ts +0 -25
  54. package/src/hooks/useInterval.ts +0 -27
  55. package/src/input-event.ts +0 -32
@@ -2,7 +2,11 @@ import { ComponentPropsWithoutRef, ReactNode, CSSProperties } from 'react'
2
2
 
3
3
  import { useLongPress } from '../../hooks/useLongPress'
4
4
 
5
- import { useNumberInputContext, useStepperContext } from './context'
5
+ import {
6
+ useNumberInputContext,
7
+ useNumberInputGesture,
8
+ useStepperContext,
9
+ } from './context'
6
10
 
7
11
  import type { CSSVariables } from '../../css-variables'
8
12
 
@@ -37,17 +41,17 @@ export function StepperButton({
37
41
  variant: 'increment' | 'decrement'
38
42
  icon: ReactNode
39
43
  }) {
40
- const { step, disabled, readonly, atMin, atMax, nudge } =
44
+ const { step, disabled, readOnly, atMin, atMax, nudge } =
41
45
  useNumberInputContext()
42
46
  const stepper = useStepperContext()
47
+ const gesture = useNumberInputGesture()
43
48
 
44
- // Not memoized: the callback reads `draggingRef.current`, which the compiler
45
- // cannot line up with a manual dependency list. `useLongPress` only ever
46
- // calls it through a ref, so a fresh identity per render costs nothing.
49
+ // Not memoized: `useLongPress` only ever calls it through a ref, so a fresh
50
+ // identity per render costs nothing.
47
51
  const press = useLongPress(() => {
48
52
  // Once the pointer has actually travelled, the drag on `Stepper` owns the
49
53
  // value; repeating on top of it would move it twice.
50
- if (stepper?.draggingRef.current) return
54
+ if (stepper?.moved()) return
51
55
  nudge(direction, ['raw', step])
52
56
  })
53
57
 
@@ -65,10 +69,13 @@ export function StepperButton({
65
69
  data-disabled={
66
70
  disabled || (direction > 0 ? atMax : atMin) ? '' : undefined
67
71
  }
68
- data-readonly={readonly ? '' : undefined}
72
+ data-readonly={readOnly ? '' : undefined}
69
73
  style={style}
70
74
  onPointerDown={(event) => {
71
- if (!disabled && !readonly) press(event)
75
+ if (!disabled && !readOnly) {
76
+ gesture?.press()
77
+ press(event)
78
+ }
72
79
  onPointerDown?.(event)
73
80
  }}
74
81
  {...props}
@@ -1,4 +1,4 @@
1
- import { createContext, RefObject, useContext } from 'react'
1
+ import { createContext, CSSProperties, RefObject, useContext } from 'react'
2
2
 
3
3
  import {
4
4
  type InputEventOption,
@@ -22,7 +22,7 @@ export type NumberInputContextValue = {
22
22
  scale: Scale
23
23
 
24
24
  disabled: boolean
25
- readonly: boolean
25
+ readOnly: boolean
26
26
  clampValue: boolean
27
27
 
28
28
  /**
@@ -40,6 +40,8 @@ export type NumberInputContextValue = {
40
40
  dragSensitivity: ModifierValue<number>
41
41
  /** Whether a `Stepper` drag hides the pointer and reads its movement. */
42
42
  pointerLock: boolean
43
+ /** The cursor shown while a `Stepper` is dragged. */
44
+ dragCursor: CSSProperties['cursor']
43
45
 
44
46
  /** What `InputField` selects when it takes focus. */
45
47
  selectOnFocus: 'all' | 'number' | 'none'
@@ -101,9 +103,30 @@ export function useNumberInputContext<T>(
101
103
  return selector ? selector(context) : context
102
104
  }
103
105
 
106
+ /**
107
+ * The change gesture of `Root`, for the parts that change the value. Not on
108
+ * the public context: it is how the parts report, not something to read.
109
+ */
110
+ export type NumberInputGestureValue = {
111
+ /** A press on a stepper, held until the pointer is released anywhere. */
112
+ press: () => void
113
+ /** An arrow key in `InputField`. */
114
+ key: () => void
115
+ }
116
+
117
+ const NumberInputGestureContext =
118
+ /* @__PURE__ */ createContext<NumberInputGestureValue | null>(null)
119
+
120
+ export const NumberInputGestureProvider = NumberInputGestureContext.Provider
121
+
122
+ export function useNumberInputGesture(): NumberInputGestureValue | null {
123
+ return useContext(NumberInputGestureContext)
124
+ }
125
+
104
126
  /** Set by `Stepper` once a drag has actually moved, so the steppers stand down. */
105
127
  export type StepperContextValue = {
106
- draggingRef: RefObject<boolean>
128
+ /** Whether the drag in progress has moved the value. */
129
+ moved: () => boolean
107
130
  }
108
131
 
109
132
  const StepperContext =
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  ComponentPropsWithoutRef,
3
+ CSSProperties,
3
4
  forwardRef,
4
5
  ReactNode,
6
+ Ref,
5
7
  useCallback,
6
8
  useImperativeHandle,
7
9
  useMemo,
@@ -10,23 +12,30 @@ import {
10
12
  } from 'react'
11
13
 
12
14
  import {
13
- applyDelta,
15
+ type ChangeSource,
16
+ DEFAULT_DRAG_SENSITIVITY,
17
+ DEFAULT_KEYBOARD_OPTIONS,
18
+ DEFAULT_WHEEL_OPTIONS,
14
19
  InputEventOption,
15
20
  ModifierState,
16
21
  type ModifierValue,
17
- selectModifier,
18
22
  } from '@tremolo-ui/dom'
19
- import { linearScale, type Scale, type ValueRange } from '@tremolo-ui/functions'
20
-
23
+ import {
24
+ commitNumberInputText,
25
+ numberInputBounds,
26
+ numberInputRanges,
27
+ nudgeNumberInput,
28
+ parseNumberText,
29
+ wheelDirection,
30
+ } from '@tremolo-ui/dom/internal'
31
+ import { linearScale, type Scale } from '@tremolo-ui/functions'
32
+
33
+ import { useComposedRefs } from '../../compose-refs'
34
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
21
35
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
22
36
  import { useWheel } from '../../hooks/useWheel'
23
- import {
24
- DEFAULT_DRAG_SENSITIVITY,
25
- DEFAULT_KEYBOARD_OPTIONS,
26
- DEFAULT_WHEEL_OPTIONS,
27
- } from '../../input-event'
28
37
 
29
- import { NumberInputProvider } from './context'
38
+ import { NumberInputGestureProvider, NumberInputProvider } from './context'
30
39
  import { DecrementStepper } from './DecrementStepper'
31
40
  import { IncrementStepper } from './IncrementStepper'
32
41
  import { InputField } from './InputField'
@@ -83,6 +92,9 @@ export interface NumberInputProps {
83
92
  * Read a value back out of the text. Has to undo `format`.
84
93
  *
85
94
  * Text with no number in it reads as `NaN`, which leaves the value alone.
95
+ * The default, `parseNumberText` from `@tremolo-ui/dom`, reads the number
96
+ * with the unit around it left out, and is `NaN` for one it cannot read
97
+ * whole: `1,000`, `1:30`, or a number with a unit in front, such as `L 30`.
86
98
  */
87
99
  parse?: (text: string) => number
88
100
 
@@ -157,10 +169,17 @@ export interface NumberInputProps {
157
169
  * @default false
158
170
  */
159
171
  pointerLock?: boolean
172
+ /**
173
+ * The cursor to show while dragging a `Stepper`. It is set on the stepper
174
+ * being dragged, so it stays while the pointer is outside it.
175
+ *
176
+ * @default 'ns-resize'
177
+ */
178
+ dragCursor?: CSSProperties['cursor']
160
179
 
161
180
  /**
162
181
  * Select the text when `InputField` takes focus: `'all'` selects all of it,
163
- * `'number'` only the leading number, leaving whatever the format appended,
182
+ * `'number'` only the number, leaving the unit the format put around it,
164
183
  * and `'none'` leaves the caret where the click put it.
165
184
  * @default 'none'
166
185
  */
@@ -214,7 +233,7 @@ export interface NumberInputProps {
214
233
  * Make the value unchangeable.
215
234
  * The parts carry `data-readonly` while it is set.
216
235
  */
217
- readonly?: boolean
236
+ readOnly?: boolean
218
237
 
219
238
  /**
220
239
  * Called with the new value. While the user types, it is called for every
@@ -222,6 +241,27 @@ export interface NumberInputProps {
222
241
  * again if clamping changes the value.
223
242
  */
224
243
  onChange?: (value: number) => void
244
+ /**
245
+ * Called when a change of the value starts — a press on a stepper, the
246
+ * first wheel notch, arrow key or typed character — with the value before
247
+ * it and what it is made with. A host recording automation can treat the
248
+ * input as touched from here until `onChangeEnd`.
249
+ */
250
+ onChangeStart?: (value: number, source: ChangeSource) => void
251
+ /**
252
+ * Called when the change ends, with the value it ended on: when the stepper
253
+ * is released, when typed text is committed, or `changeEndDelay` after the
254
+ * last wheel notch or arrow key.
255
+ */
256
+ onChangeEnd?: (value: number, source: ChangeSource) => void
257
+ /**
258
+ * How long after the last wheel notch, arrow key or typed character the
259
+ * change counts as over, in milliseconds. None of them has an event that
260
+ * says it is done.
261
+ *
262
+ * @default 500
263
+ */
264
+ changeEndDelay?: number
225
265
 
226
266
  /**
227
267
  * The input renders exactly what you compose here; there is no default
@@ -237,6 +277,13 @@ export interface NumberInputProps {
237
277
  * </NumberInput.Root>
238
278
  */
239
279
  children: ReactNode
280
+
281
+ /**
282
+ * Receives `focus` and `blur`, which act on the input of `InputField` — the
283
+ * element that takes the focus — and do nothing while
284
+ * the input is disabled. `ref` reaches the root element itself.
285
+ */
286
+ actionsRef?: Ref<NumberInputMethods>
240
287
  }
241
288
 
242
289
  export interface NumberInputMethods {
@@ -244,24 +291,12 @@ export interface NumberInputMethods {
244
291
  blur: () => void
245
292
  }
246
293
 
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
294
  const defaultFormat = (value: number) => String(value)
255
295
 
256
- const defaultParse = (text: string) => {
257
- const match = text.match(LEADING_NUMBER)
258
- return match ? Number(match[1]) : NaN
259
- }
260
-
261
296
  type Props = NumberInputProps &
262
297
  Omit<ComponentPropsWithoutRef<'div'>, keyof NumberInputProps>
263
298
 
264
- export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
299
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
265
300
  (
266
301
  {
267
302
  value,
@@ -277,16 +312,21 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
277
312
  drag = 1,
278
313
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
279
314
  pointerLock = false,
315
+ dragCursor = 'ns-resize',
280
316
  selectOnFocus = 'none',
281
317
  unformatOnFocus = false,
282
318
  keepCaretOnStep = false,
283
319
  blurOnEnter = true,
284
320
  disabled = false,
285
- readonly = false,
321
+ readOnly = false,
286
322
  className,
287
323
  style,
288
324
  onChange,
325
+ onChangeStart,
326
+ onChangeEnd,
327
+ changeEndDelay,
289
328
  children,
329
+ actionsRef,
290
330
  ...props
291
331
  }: Props,
292
332
  forwardedRef,
@@ -298,36 +338,25 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
298
338
  * but the half-finished entry that has no value to be derived from yet.
299
339
  */
300
340
  const [draft, setDraft] = useState<string | null>(null)
301
- const inactive = disabled || readonly
341
+ const inactive = disabled || readOnly
342
+
343
+ const gesture = useChangeGesture(
344
+ value,
345
+ { onChangeStart, onChangeEnd, changeEndDelay },
346
+ inactive,
347
+ )
302
348
 
303
349
  // --- interpret props ---
304
350
  const format = formatProp ?? defaultFormat
305
- const parse = parseProp ?? defaultParse
351
+ const parse = parseProp ?? parseNumberText
306
352
 
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.
310
- const range: ValueRange = useMemo(
311
- () => ({
312
- min: (clampValue ? min : undefined) ?? Number.MIN_SAFE_INTEGER,
313
- max: (clampValue ? max : undefined) ?? Number.MAX_SAFE_INTEGER,
314
- step,
315
- scale,
316
- }),
353
+ const { normalized: range, raw: rawRange } = useMemo(
354
+ () => numberInputRanges({ min, max, step, scale, clampValue }),
317
355
  [clampValue, min, max, step, scale],
318
356
  )
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],
357
+ const ranges = useMemo(
358
+ () => ({ normalized: range, raw: rawRange }),
359
+ [range, rawRange],
331
360
  )
332
361
 
333
362
  // The pipeline range stands in the safe-integer range when an end is
@@ -351,9 +380,9 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
351
380
 
352
381
  const text = draft ?? format(value)
353
382
  const editing = draft !== null
354
- const outOfRange =
355
- !editing &&
356
- ((min !== undefined && value < min) || (max !== undefined && value > max))
383
+ const bounds = numberInputBounds(value, { min, max, clampValue })
384
+ const { atMin, atMax } = bounds
385
+ const outOfRange = !editing && bounds.outOfRange
357
386
 
358
387
  // --- internal functions ---
359
388
  const handleDraft = useCallback(
@@ -363,35 +392,55 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
363
392
  // Deliberately unclamped: clamping here would make "1500" impossible
364
393
  // to type into an input whose max is 100.
365
394
  const parsed = parse(next)
366
- if (Number.isFinite(parsed)) onChange?.(parsed)
395
+ if (!Number.isFinite(parsed)) return
396
+ if (parsed !== value) {
397
+ gesture.pulse('keyboard')
398
+ gesture.changed(parsed)
399
+ }
400
+ onChange?.(parsed)
367
401
  },
368
- [inactive, parse, onChange],
402
+ [inactive, parse, onChange, value, gesture],
369
403
  )
370
404
 
371
405
  const changeValue = useCallback(
372
406
  (next: number) => {
373
407
  if (inactive) return
374
408
  setDraft(null)
375
- if (next !== value) onChange?.(next)
409
+ if (next === value) return
410
+ gesture.changed(next)
411
+ onChange?.(next)
376
412
  },
377
- [inactive, value, onChange],
413
+ [inactive, value, onChange, gesture],
378
414
  )
379
415
 
380
416
  const commitDraft = useCallback(() => {
381
417
  if (draft === null || inactive) return
382
- const parsed = parse(draft)
418
+ const committed = commitNumberInputText(draft, parse, {
419
+ min,
420
+ max,
421
+ clampValue,
422
+ })
383
423
  // 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)) {
424
+ // input back to what it was showing.
425
+ if (committed === null) {
387
426
  setDraft(null)
388
427
  return
389
428
  }
390
- let committed = parsed
391
- if (clampValue && min !== undefined) committed = Math.max(committed, min)
392
- if (clampValue && max !== undefined) committed = Math.min(committed, max)
429
+ // The typing was a gesture of its own, and committing is where it ends.
430
+ if (committed !== value) gesture.pulse('keyboard')
393
431
  changeValue(committed)
394
- }, [draft, inactive, parse, clampValue, min, max, changeValue])
432
+ gesture.end()
433
+ }, [
434
+ draft,
435
+ inactive,
436
+ parse,
437
+ clampValue,
438
+ min,
439
+ max,
440
+ changeValue,
441
+ value,
442
+ gesture,
443
+ ])
395
444
 
396
445
  const nudge = useCallback(
397
446
  (
@@ -399,26 +448,21 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
399
448
  option: ModifierValue<InputEventOption>,
400
449
  modifiers?: ModifierState,
401
450
  ) => {
402
- const [mode] = selectModifier(option, modifiers).value
403
451
  changeValue(
404
- applyDelta(
405
- value,
406
- direction,
407
- option,
408
- mode === 'raw' ? rawRange : range,
409
- modifiers,
410
- ),
452
+ nudgeNumberInput(value, direction, option, ranges, modifiers),
411
453
  )
412
454
  },
413
- [changeValue, value, rawRange, range],
455
+ [changeValue, value, ranges],
414
456
  )
415
457
 
416
458
  // --- hooks ---
417
459
  const wheelRefCallback = useWheel<HTMLDivElement>(
418
460
  (event) => {
419
- if (!wheel || inactive || event.deltaY === 0) return
461
+ const direction = wheelDirection(event)
462
+ if (!wheel || inactive || direction === null) return
420
463
  event.preventDefault()
421
- nudge(-Math.sign(event.deltaY), wheel, event)
464
+ gesture.pulse('wheel')
465
+ nudge(direction, wheel, event)
422
466
  },
423
467
  { requireFocus: true },
424
468
  )
@@ -431,7 +475,7 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
431
475
  step,
432
476
  scale,
433
477
  disabled,
434
- readonly,
478
+ readOnly,
435
479
  clampValue,
436
480
  range,
437
481
  rawRange,
@@ -441,12 +485,13 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
441
485
  outOfRange,
442
486
  dragSensitivity,
443
487
  pointerLock,
488
+ dragCursor,
444
489
  selectOnFocus,
445
490
  unformatOnFocus,
446
491
  keepCaretOnStep,
447
492
  blurOnEnter,
448
- atMin: clampValue && min !== undefined && value <= min,
449
- atMax: clampValue && max !== undefined && value >= max,
493
+ atMin,
494
+ atMax,
450
495
  drag,
451
496
  format,
452
497
  parse,
@@ -463,7 +508,7 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
463
508
  step,
464
509
  scale,
465
510
  disabled,
466
- readonly,
511
+ readOnly,
467
512
  clampValue,
468
513
  range,
469
514
  rawRange,
@@ -471,9 +516,12 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
471
516
  text,
472
517
  editing,
473
518
  outOfRange,
519
+ atMin,
520
+ atMax,
474
521
  drag,
475
522
  dragSensitivity,
476
523
  pointerLock,
524
+ dragCursor,
477
525
  selectOnFocus,
478
526
  unformatOnFocus,
479
527
  keepCaretOnStep,
@@ -487,7 +535,14 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
487
535
  ],
488
536
  )
489
537
 
490
- useImperativeHandle(forwardedRef, () => {
538
+ // Composed once, so React attaches the refs a single time instead of
539
+ // detaching and re-attaching on every render.
540
+ const rootRefCallback = useComposedRefs<HTMLDivElement>(
541
+ forwardedRef,
542
+ wheelRefCallback,
543
+ )
544
+
545
+ useImperativeHandle(actionsRef, () => {
491
546
  return {
492
547
  focus() {
493
548
  if (!disabled) inputRef.current?.focus()
@@ -498,18 +553,47 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
498
553
  }
499
554
  }, [disabled])
500
555
 
556
+ const pressed = useRef(false)
557
+ const gestureContext = useMemo(
558
+ () => ({
559
+ press: () => {
560
+ if (inactive) return
561
+ gesture.hold('pointer')
562
+ // A press on a stepper reaches `Stepper` too; one release will do.
563
+ if (pressed.current) return
564
+ pressed.current = true
565
+ // Released wherever the pointer ends up: the stepper drag captures
566
+ // it, and a press that wanders off the button still ends.
567
+ const release = () => {
568
+ pressed.current = false
569
+ gesture.end()
570
+ window.removeEventListener('pointerup', release)
571
+ window.removeEventListener('pointercancel', release)
572
+ }
573
+ window.addEventListener('pointerup', release)
574
+ window.addEventListener('pointercancel', release)
575
+ },
576
+ key: () => {
577
+ if (!inactive) gesture.pulse('keyboard')
578
+ },
579
+ }),
580
+ [gesture, inactive],
581
+ )
582
+
501
583
  return (
502
584
  <NumberInputProvider value={context}>
503
- <div
504
- ref={wheelRefCallback}
505
- className={className}
506
- data-disabled={disabled ? '' : undefined}
507
- data-readonly={readonly ? '' : undefined}
508
- style={style}
509
- {...props}
510
- >
511
- {children}
512
- </div>
585
+ <NumberInputGestureProvider value={gestureContext}>
586
+ <div
587
+ ref={rootRefCallback}
588
+ className={className}
589
+ data-disabled={disabled ? '' : undefined}
590
+ data-readonly={readOnly ? '' : undefined}
591
+ style={style}
592
+ {...props}
593
+ >
594
+ {children}
595
+ </div>
596
+ </NumberInputGestureProvider>
513
597
  </NumberInputProvider>
514
598
  )
515
599
  },