@tremolo-ui/react 0.8.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 (40) hide show
  1. package/dist/index.cjs +416 -252
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +256 -110
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +256 -110
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +368 -203
  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/FileInput/index.tsx +4 -1
  12. package/src/components/Knob/Thumb.tsx +19 -19
  13. package/src/components/Knob/index.tsx +85 -41
  14. package/src/components/NumberInput/InputField.tsx +9 -7
  15. package/src/components/NumberInput/Stepper.tsx +30 -6
  16. package/src/components/NumberInput/StepperButton.tsx +12 -4
  17. package/src/components/NumberInput/context.tsx +24 -2
  18. package/src/components/NumberInput/index.tsx +142 -27
  19. package/src/components/Piano/index.tsx +24 -7
  20. package/src/components/PointsEditor/Container.tsx +1 -1
  21. package/src/components/PointsEditor/Point.tsx +61 -30
  22. package/src/components/PointsEditor/context.tsx +3 -4
  23. package/src/components/PointsEditor/index.tsx +14 -15
  24. package/src/components/Slider/Marks.tsx +3 -2
  25. package/src/components/Slider/MarksOption.tsx +3 -2
  26. package/src/components/Slider/Thumb.tsx +20 -11
  27. package/src/components/Slider/Track.tsx +15 -13
  28. package/src/components/Slider/context.tsx +3 -3
  29. package/src/components/Slider/index.tsx +85 -48
  30. package/src/components/XYPad/Thumb.tsx +18 -8
  31. package/src/components/XYPad/context.tsx +1 -1
  32. package/src/components/XYPad/index.tsx +74 -42
  33. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  34. package/src/hooks/_internal/useChangeGesture.ts +93 -0
  35. package/src/hooks/_internal/useCheckSteps.ts +1 -1
  36. package/src/hooks/useAnimationFrame.ts +37 -15
  37. package/src/hooks/useEventListener.ts +93 -22
  38. package/src/hooks/useWheel.ts +4 -4
  39. package/src/index.ts +9 -5
  40. package/src/hooks/useInterval.ts +0 -27
@@ -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,6 +103,26 @@ 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
128
  /** Whether the drag in progress has moved the value. */
@@ -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,25 +12,30 @@ import {
10
12
  } from 'react'
11
13
 
12
14
  import {
15
+ type ChangeSource,
13
16
  DEFAULT_DRAG_SENSITIVITY,
14
17
  DEFAULT_KEYBOARD_OPTIONS,
15
18
  DEFAULT_WHEEL_OPTIONS,
16
- commitNumberInputText,
17
19
  InputEventOption,
18
20
  ModifierState,
19
21
  type ModifierValue,
22
+ } from '@tremolo-ui/dom'
23
+ import {
24
+ commitNumberInputText,
20
25
  numberInputBounds,
21
26
  numberInputRanges,
22
27
  nudgeNumberInput,
23
28
  parseNumberText,
24
29
  wheelDirection,
25
- } from '@tremolo-ui/dom'
30
+ } from '@tremolo-ui/dom/internal'
26
31
  import { linearScale, type Scale } from '@tremolo-ui/functions'
27
32
 
33
+ import { useComposedRefs } from '../../compose-refs'
34
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
28
35
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
29
36
  import { useWheel } from '../../hooks/useWheel'
30
37
 
31
- import { NumberInputProvider } from './context'
38
+ import { NumberInputGestureProvider, NumberInputProvider } from './context'
32
39
  import { DecrementStepper } from './DecrementStepper'
33
40
  import { IncrementStepper } from './IncrementStepper'
34
41
  import { InputField } from './InputField'
@@ -162,6 +169,13 @@ export interface NumberInputProps {
162
169
  * @default false
163
170
  */
164
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']
165
179
 
166
180
  /**
167
181
  * Select the text when `InputField` takes focus: `'all'` selects all of it,
@@ -219,7 +233,7 @@ export interface NumberInputProps {
219
233
  * Make the value unchangeable.
220
234
  * The parts carry `data-readonly` while it is set.
221
235
  */
222
- readonly?: boolean
236
+ readOnly?: boolean
223
237
 
224
238
  /**
225
239
  * Called with the new value. While the user types, it is called for every
@@ -227,6 +241,27 @@ export interface NumberInputProps {
227
241
  * again if clamping changes the value.
228
242
  */
229
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
230
265
 
231
266
  /**
232
267
  * The input renders exactly what you compose here; there is no default
@@ -242,6 +277,13 @@ export interface NumberInputProps {
242
277
  * </NumberInput.Root>
243
278
  */
244
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>
245
287
  }
246
288
 
247
289
  export interface NumberInputMethods {
@@ -254,7 +296,7 @@ const defaultFormat = (value: number) => String(value)
254
296
  type Props = NumberInputProps &
255
297
  Omit<ComponentPropsWithoutRef<'div'>, keyof NumberInputProps>
256
298
 
257
- export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
299
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
258
300
  (
259
301
  {
260
302
  value,
@@ -270,16 +312,21 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
270
312
  drag = 1,
271
313
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
272
314
  pointerLock = false,
315
+ dragCursor = 'ns-resize',
273
316
  selectOnFocus = 'none',
274
317
  unformatOnFocus = false,
275
318
  keepCaretOnStep = false,
276
319
  blurOnEnter = true,
277
320
  disabled = false,
278
- readonly = false,
321
+ readOnly = false,
279
322
  className,
280
323
  style,
281
324
  onChange,
325
+ onChangeStart,
326
+ onChangeEnd,
327
+ changeEndDelay,
282
328
  children,
329
+ actionsRef,
283
330
  ...props
284
331
  }: Props,
285
332
  forwardedRef,
@@ -291,7 +338,13 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
291
338
  * but the half-finished entry that has no value to be derived from yet.
292
339
  */
293
340
  const [draft, setDraft] = useState<string | null>(null)
294
- const inactive = disabled || readonly
341
+ const inactive = disabled || readOnly
342
+
343
+ const gesture = useChangeGesture(
344
+ value,
345
+ { onChangeStart, onChangeEnd, changeEndDelay },
346
+ inactive,
347
+ )
295
348
 
296
349
  // --- interpret props ---
297
350
  const format = formatProp ?? defaultFormat
@@ -339,18 +392,25 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
339
392
  // Deliberately unclamped: clamping here would make "1500" impossible
340
393
  // to type into an input whose max is 100.
341
394
  const parsed = parse(next)
342
- 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)
343
401
  },
344
- [inactive, parse, onChange],
402
+ [inactive, parse, onChange, value, gesture],
345
403
  )
346
404
 
347
405
  const changeValue = useCallback(
348
406
  (next: number) => {
349
407
  if (inactive) return
350
408
  setDraft(null)
351
- if (next !== value) onChange?.(next)
409
+ if (next === value) return
410
+ gesture.changed(next)
411
+ onChange?.(next)
352
412
  },
353
- [inactive, value, onChange],
413
+ [inactive, value, onChange, gesture],
354
414
  )
355
415
 
356
416
  const commitDraft = useCallback(() => {
@@ -362,9 +422,25 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
362
422
  })
363
423
  // Text with no number in it is not a value. Dropping the draft puts the
364
424
  // input back to what it was showing.
365
- if (committed === null) setDraft(null)
366
- else changeValue(committed)
367
- }, [draft, inactive, parse, clampValue, min, max, changeValue])
425
+ if (committed === null) {
426
+ setDraft(null)
427
+ return
428
+ }
429
+ // The typing was a gesture of its own, and committing is where it ends.
430
+ if (committed !== value) gesture.pulse('keyboard')
431
+ changeValue(committed)
432
+ gesture.end()
433
+ }, [
434
+ draft,
435
+ inactive,
436
+ parse,
437
+ clampValue,
438
+ min,
439
+ max,
440
+ changeValue,
441
+ value,
442
+ gesture,
443
+ ])
368
444
 
369
445
  const nudge = useCallback(
370
446
  (
@@ -385,6 +461,7 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
385
461
  const direction = wheelDirection(event)
386
462
  if (!wheel || inactive || direction === null) return
387
463
  event.preventDefault()
464
+ gesture.pulse('wheel')
388
465
  nudge(direction, wheel, event)
389
466
  },
390
467
  { requireFocus: true },
@@ -398,7 +475,7 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
398
475
  step,
399
476
  scale,
400
477
  disabled,
401
- readonly,
478
+ readOnly,
402
479
  clampValue,
403
480
  range,
404
481
  rawRange,
@@ -408,6 +485,7 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
408
485
  outOfRange,
409
486
  dragSensitivity,
410
487
  pointerLock,
488
+ dragCursor,
411
489
  selectOnFocus,
412
490
  unformatOnFocus,
413
491
  keepCaretOnStep,
@@ -430,7 +508,7 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
430
508
  step,
431
509
  scale,
432
510
  disabled,
433
- readonly,
511
+ readOnly,
434
512
  clampValue,
435
513
  range,
436
514
  rawRange,
@@ -443,6 +521,7 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
443
521
  drag,
444
522
  dragSensitivity,
445
523
  pointerLock,
524
+ dragCursor,
446
525
  selectOnFocus,
447
526
  unformatOnFocus,
448
527
  keepCaretOnStep,
@@ -456,7 +535,14 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
456
535
  ],
457
536
  )
458
537
 
459
- 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, () => {
460
546
  return {
461
547
  focus() {
462
548
  if (!disabled) inputRef.current?.focus()
@@ -467,18 +553,47 @@ export const Root = /* @__PURE__ */ forwardRef<NumberInputMethods, Props>(
467
553
  }
468
554
  }, [disabled])
469
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
+
470
583
  return (
471
584
  <NumberInputProvider value={context}>
472
- <div
473
- ref={wheelRefCallback}
474
- className={className}
475
- data-disabled={disabled ? '' : undefined}
476
- data-readonly={readonly ? '' : undefined}
477
- style={style}
478
- {...props}
479
- >
480
- {children}
481
- </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>
482
597
  </NumberInputProvider>
483
598
  )
484
599
  },
@@ -3,6 +3,7 @@ import {
3
3
  CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
+ Ref,
6
7
  useEffect,
7
8
  useImperativeHandle,
8
9
  useMemo,
@@ -11,20 +12,24 @@ import {
11
12
  } from 'react'
12
13
 
13
14
  import {
14
- blackKeyWidth,
15
15
  createPianoInput,
16
- fitWhiteKeyWidth,
17
- getNoteRangeArray,
18
16
  type KeyboardShortcuts,
19
17
  type KeyboardShortcutsScope,
20
18
  notePosition,
21
- pianoWidth,
22
19
  type NoteRange,
23
20
  type PianoInputInstance,
24
21
  type PianoLayout,
25
22
  } from '@tremolo-ui/dom'
23
+ import {
24
+ blackKeyWidth,
25
+ fitWhiteKeyWidth,
26
+ getNoteRangeArray,
27
+ pianoWidth,
28
+ } from '@tremolo-ui/dom/internal'
26
29
  import { isWhiteKey, noteKey } from '@tremolo-ui/functions'
27
30
 
31
+ import { useComposedRefs } from '../../compose-refs'
32
+
28
33
  /**
29
34
  * What {@link PianoProps.keyProps} may return for one key.
30
35
  *
@@ -155,6 +160,12 @@ export interface PianoProps {
155
160
  onPlayNote?: (note: number, velocity?: number) => void
156
161
  /** Called once everything holding a note has let go of it. */
157
162
  onStopNote?: (note: number) => void
163
+
164
+ /**
165
+ * Receives `playNote` and `stopNote`, which press and release a key as the
166
+ * pointer or the keyboard would. `ref` reaches the root element itself.
167
+ */
168
+ actionsRef?: Ref<PianoMethods>
158
169
  }
159
170
 
160
171
  export interface PianoMethods {
@@ -165,7 +176,7 @@ export interface PianoMethods {
165
176
  type Props = PianoProps &
166
177
  Omit<ComponentPropsWithoutRef<'div'>, keyof PianoProps>
167
178
 
168
- export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
179
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
169
180
  function Root(
170
181
  {
171
182
  noteRange,
@@ -185,6 +196,7 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
185
196
  classes,
186
197
  onPlayNote,
187
198
  onStopNote,
199
+ actionsRef,
188
200
  ...props
189
201
  },
190
202
  forwardedRef,
@@ -295,8 +307,13 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
295
307
  return () => resizeObserver.disconnect()
296
308
  }, [resizable, node, first, last, keyGap, blackKeyWidthRatio])
297
309
 
298
- useImperativeHandle(
310
+ const rootRefCallback = useComposedRefs<HTMLDivElement>(
299
311
  forwardedRef,
312
+ setNode,
313
+ )
314
+
315
+ useImperativeHandle(
316
+ actionsRef,
300
317
  () => ({
301
318
  playNote: (note, velocity) =>
302
319
  instanceRef.current?.noteOn(note, { source: 'api', velocity }),
@@ -308,7 +325,7 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
308
325
 
309
326
  return (
310
327
  <div
311
- ref={setNode}
328
+ ref={rootRefCallback}
312
329
  className={className}
313
330
  data-resizable={resizable ? '' : undefined}
314
331
  role="group"
@@ -1,6 +1,6 @@
1
1
  import { ComponentPropsWithoutRef, forwardRef } from 'react'
2
2
 
3
- import { POINT_AXIS, wheelMove } from '@tremolo-ui/dom'
3
+ import { POINT_AXIS, wheelMove } from '@tremolo-ui/dom/internal'
4
4
 
5
5
  import { useComposedRefs } from '../../compose-refs'
6
6
  import { useDragValue } from '../../hooks/useDragValue'
@@ -8,17 +8,21 @@ import {
8
8
  } from 'react'
9
9
 
10
10
  import {
11
- arrowKeyMove,
12
- clampPoint,
11
+ type ChangeSource,
13
12
  type InputEventOption,
14
13
  type ModifierValue,
15
- POINT_AXIS,
16
14
  type PointPosition,
17
15
  type PointsEditorPoint,
18
- selectModifier,
19
16
  } from '@tremolo-ui/dom'
17
+ import {
18
+ arrowKeyMove,
19
+ clampPoint,
20
+ POINT_AXIS,
21
+ selectModifier,
22
+ } from '@tremolo-ui/dom/internal'
20
23
 
21
24
  import { useComposedRefs } from '../../compose-refs'
25
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
22
26
  import { useDragValue } from '../../hooks/useDragValue'
23
27
  import { useCheckPlacement } from '../_util/Placement'
24
28
  import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
@@ -49,8 +53,8 @@ export interface PointsEditorPointProps<T extends PointPosition> {
49
53
 
50
54
  /** Overrides the `disabled` of `PointsEditor.Root`. */
51
55
  disabled?: boolean
52
- /** Overrides the `readonly` of `PointsEditor.Root`. */
53
- readonly?: boolean
56
+ /** Overrides the `readOnly` of `PointsEditor.Root`. */
57
+ readOnly?: boolean
54
58
 
55
59
  /** Overrides the `wheel` of `PointsEditor.Root`. */
56
60
  wheel?: ModifierValue<InputEventOption> | null
@@ -70,10 +74,25 @@ export interface PointsEditorPointProps<T extends PointPosition> {
70
74
  * arrow keys or the wheel, including when it moves along with a selection.
71
75
  */
72
76
  onChange?: (value: PointPosition) => void
73
- /** Called when a drag on this point starts, with where the point is. */
74
- onDragStart?: (value: PointPosition) => void
75
- /** Called when that drag ends, with where the point is. */
76
- onDragEnd?: (value: PointPosition) => void
77
+ /**
78
+ * Called when a change of this point starts — a drag on it, the first wheel
79
+ * notch or arrow key while it has the focus — with where it is and what the
80
+ * change is made with. The points that move along with a selection report
81
+ * through `onChange` only: the change belongs to the point being operated.
82
+ */
83
+ onChangeStart?: (value: PointPosition, source: ChangeSource) => void
84
+ /**
85
+ * Called when that change ends, with where the point is: on release, or
86
+ * `changeEndDelay` after the last wheel notch or arrow key.
87
+ */
88
+ onChangeEnd?: (value: PointPosition, source: ChangeSource) => void
89
+ /**
90
+ * How long after the last wheel notch or arrow key the change counts as
91
+ * over, in milliseconds. Neither has an event that says it is done.
92
+ *
93
+ * @default 500
94
+ */
95
+ changeEndDelay?: number
77
96
 
78
97
  style?: CSSProperties & CSSVariables<'color' | 'translate'>
79
98
  }
@@ -98,15 +117,16 @@ export function Point<T extends PointPosition>({
98
117
  color,
99
118
 
100
119
  disabled: _disabled,
101
- readonly: _readonly,
120
+ readOnly: _readOnly,
102
121
  wheel: _wheel,
103
122
  keyboard: _keyboard,
104
123
  'aria-label': ariaLabel,
105
124
  'aria-valuetext': ariaValuetext,
106
125
 
107
126
  onChange,
108
- onDragStart,
109
- onDragEnd,
127
+ onChangeStart,
128
+ onChangeEnd,
129
+ changeEndDelay,
110
130
 
111
131
  className,
112
132
  style,
@@ -118,9 +138,9 @@ export function Point<T extends PointPosition>({
118
138
  Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorPointProps<T>>) {
119
139
  const {
120
140
  containerRef,
121
- externalStyles,
141
+ dragCursor,
122
142
  disabled: rootDisabled,
123
- readonly: rootReadonly,
143
+ readOnly: rootReadOnly,
124
144
  wheel: rootWheel,
125
145
  keyboard: rootKeyboard,
126
146
  dragSensitivity,
@@ -133,8 +153,8 @@ export function Point<T extends PointPosition>({
133
153
  const selected = selection.includes(id)
134
154
 
135
155
  const disabled = _disabled ?? rootDisabled
136
- const readonly = _readonly ?? rootReadonly
137
- const inactive = disabled || readonly
156
+ const readOnly = _readOnly ?? rootReadOnly
157
+ const inactive = disabled || readOnly
138
158
  // `null` means "no event" and has to survive the fallback, so `??` is not
139
159
  // enough: only an omitted prop inherits from the root.
140
160
  const wheel = _wheel === undefined ? rootWheel : _wheel
@@ -142,6 +162,19 @@ export function Point<T extends PointPosition>({
142
162
 
143
163
  useCheckPlacement('PointsEditor.Point', 'PointsEditor.Container')
144
164
 
165
+ const current = clampPoint(value, min, max)
166
+ const gesture = useChangeGesture(
167
+ current,
168
+ { onChangeStart, onChangeEnd, changeEndDelay },
169
+ inactive,
170
+ )
171
+ // Every move reaches the point through here, whatever moved it, so the
172
+ // last one is what `onChangeEnd` gives.
173
+ const handleChange = (next: PointPosition) => {
174
+ gesture.changed(next)
175
+ onChange?.(next)
176
+ }
177
+
145
178
  // Compared against the focus below, so the point needs its own element.
146
179
  const [element, setElement] = useState<HTMLDivElement | null>(null)
147
180
  const xInputRef = useRef<HTMLInputElement>(null)
@@ -155,9 +188,10 @@ export function Point<T extends PointPosition>({
155
188
  min,
156
189
  max,
157
190
  readonly: inactive,
158
- onChange,
191
+ onChange: onChange ? handleChange : undefined,
159
192
  element,
160
193
  wheel,
194
+ beforeWheel: () => gesture.pulse('wheel'),
161
195
  })
162
196
  useEffect(() => {
163
197
  registration.current = {
@@ -165,9 +199,10 @@ export function Point<T extends PointPosition>({
165
199
  min,
166
200
  max,
167
201
  readonly: inactive,
168
- onChange,
202
+ onChange: onChange ? handleChange : undefined,
169
203
  element,
170
204
  wheel,
205
+ beforeWheel: () => gesture.pulse('wheel'),
171
206
  }
172
207
  })
173
208
 
@@ -186,7 +221,7 @@ export function Point<T extends PointPosition>({
186
221
  baseElementRef: containerRef,
187
222
  sensitivity: (state) =>
188
223
  selectModifier(dragSensitivity, state.event).value,
189
- cursor: inactive ? undefined : externalStyles.cursor,
224
+ cursor: inactive ? undefined : dragCursor,
190
225
  shouldStart: () => !disabled,
191
226
  // The value is a move rather than a position: the point keeps the offset
192
227
  // it was grabbed at, and everything else selected moves with it by the
@@ -204,14 +239,11 @@ export function Point<T extends PointPosition>({
204
239
  pointerOrigin.current = { x, y }
205
240
  xInputRef.current?.focus()
206
241
 
207
- if (inactive) return
208
- onDragStart?.(clampPoint(value, min, max))
242
+ if (!inactive) gesture.hold('pointer')
209
243
  },
210
244
  onDragEnd: () => {
211
245
  pointerOrigin.current = null
212
-
213
- if (inactive) return
214
- onDragEnd?.(clampPoint(value, min, max))
246
+ gesture.end()
215
247
  },
216
248
  })
217
249
 
@@ -229,6 +261,7 @@ export function Point<T extends PointPosition>({
229
261
  if (!move) return
230
262
  event.preventDefault()
231
263
  if (!onChange || inactive || !keyboard) return
264
+ gesture.pulse('keyboard')
232
265
  editor.nudgePoint(
233
266
  id,
234
267
  move.axis === 0 ? 'x' : 'y',
@@ -238,8 +271,6 @@ export function Point<T extends PointPosition>({
238
271
  )
239
272
  }
240
273
 
241
- const current = clampPoint(value, min, max)
242
-
243
274
  return (
244
275
  // The visual point has two values, so its semantics live on the two range
245
276
  // inputs nested inside it rather than on this drag handle.
@@ -248,7 +279,7 @@ export function Point<T extends PointPosition>({
248
279
  ref={refCallback}
249
280
  className={className}
250
281
  data-disabled={disabled ? '' : undefined}
251
- data-readonly={readonly ? '' : undefined}
282
+ data-readonly={readOnly ? '' : undefined}
252
283
  // A press lands on the point, which cannot hold focus, and the browser
253
284
  // answers that by clearing the focus to the body — undoing the focus the
254
285
  // drag just gave the input. Taking the focus here keeps it inside, and
@@ -294,12 +325,12 @@ export function Point<T extends PointPosition>({
294
325
  max={max?.[axis] ?? 1}
295
326
  step="any"
296
327
  disabled={disabled}
297
- aria-readonly={readonly}
328
+ aria-readonly={readOnly}
298
329
  aria-orientation={axis === 'x' ? 'horizontal' : 'vertical'}
299
330
  aria-label={perAxis(ariaLabel, axis) ?? axis}
300
331
  aria-valuetext={perAxis(ariaValuetext, axis)}
301
332
  onChange={(event) => {
302
- if (readonly) {
333
+ if (readOnly) {
303
334
  event.currentTarget.value = String(current[axis])
304
335
  return
305
336
  }