@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
@@ -3,6 +3,7 @@ import {
3
3
  CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
+ Ref,
6
7
  useCallback,
7
8
  useImperativeHandle,
8
9
  useMemo,
@@ -10,38 +11,39 @@ import {
10
11
  } from 'react'
11
12
 
12
13
  import {
13
- applyDelta,
14
14
  type AxisOptions,
15
+ type ChangeSource,
16
+ DEFAULT_DRAG_SENSITIVITY,
17
+ DEFAULT_KEYBOARD_OPTIONS,
18
+ DEFAULT_WHEEL_OPTIONS,
15
19
  type InputEventOption,
20
+ knobAngles,
16
21
  type ModifierValue,
17
- selectModifier,
18
22
  type XY,
19
23
  } from '@tremolo-ui/dom'
24
+ import {
25
+ applyDelta,
26
+ arrowKeyDirection,
27
+ cssLength,
28
+ selectModifier,
29
+ wheelDirection,
30
+ } from '@tremolo-ui/dom/internal'
20
31
  import { linearScale, type Scale, type ValueRange } from '@tremolo-ui/functions'
21
32
 
22
33
  import { useComposedRefs } from '../../compose-refs'
34
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
23
35
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
24
36
  import { useDragValue } from '../../hooks/useDragValue'
25
37
  import { useWheel } from '../../hooks/useWheel'
26
- import {
27
- DEFAULT_DRAG_SENSITIVITY,
28
- DEFAULT_KEYBOARD_OPTIONS,
29
- DEFAULT_WHEEL_OPTIONS,
30
- } from '../../input-event'
31
- import { cssLength } from '../_util/css-length'
32
38
 
33
39
  import { ActiveLine } from './ActiveLine'
34
- import { calcAngles, KnobProvider } from './context'
40
+ import { KnobProvider } from './context'
35
41
  import { InactiveLine } from './InactiveLine'
36
42
  import { SVGRoot } from './SVGRoot'
37
43
  import { Thumb } from './Thumb'
38
44
 
39
45
  import type { CSSVariables } from '../../css-variables'
40
46
 
41
- const defaultExternalStyles: KnobProps['externalStyles'] = {
42
- cursor: 'grabbing',
43
- }
44
-
45
47
  export interface KnobProps {
46
48
  /** The current value. The knob shows only this, so update it from `onChange`. */
47
49
  value: number
@@ -68,10 +70,10 @@ export interface KnobProps {
68
70
  */
69
71
  scale?: Scale
70
72
  /**
71
- * The value a double click restores, while `enableDoubleClickDefault` is on.
72
- * @default min
73
+ * The value a double click restores. `null` turns the double click off.
74
+ * @default startValue
73
75
  */
74
- defaultValue?: number
76
+ resetValue?: number | null
75
77
 
76
78
  /**
77
79
  * Where the active arc starts. Put it at the centre of a bipolar control,
@@ -90,11 +92,9 @@ export interface KnobProps {
90
92
  * The cursor to show while dragging. It is set on the dragged element, so it
91
93
  * stays while the pointer is outside the knob.
92
94
  *
93
- * @default { cursor: 'grabbing' }
95
+ * @default 'grabbing'
94
96
  */
95
- externalStyles?: {
96
- cursor?: CSSProperties['cursor']
97
- }
97
+ dragCursor?: CSSProperties['cursor']
98
98
  /**
99
99
  * How much one notch of the wheel moves the value. It only acts while the
100
100
  * focus is inside, so that scrolling the page past the knob leaves it alone.
@@ -157,11 +157,6 @@ export interface KnobProps {
157
157
  * @default { default: ['raw', 1], shift: ['raw', 0.1] }
158
158
  */
159
159
  keyboard?: ModifierValue<InputEventOption> | null
160
- /**
161
- * Restore `defaultValue` on a double click.
162
- * @default true
163
- */
164
- enableDoubleClickDefault?: boolean
165
160
 
166
161
  /**
167
162
  * Make the knob unchangeable and remove it from the tab order.
@@ -172,7 +167,7 @@ export interface KnobProps {
172
167
  * Make the knob unchangeable while leaving it focusable.
173
168
  * The parts carry `data-readonly` while it is set.
174
169
  */
175
- readonly?: boolean
170
+ readOnly?: boolean
176
171
 
177
172
  /**
178
173
  * How far the knob turns from `min` to `max`, in degrees, centred on the
@@ -186,6 +181,25 @@ export interface KnobProps {
186
181
  * click moves it.
187
182
  */
188
183
  onChange?: (value: number) => void
184
+ /**
185
+ * Called when a change of the value starts — a drag, a double click, the first wheel notch
186
+ * or arrow key — with the value
187
+ * before it and what it is made with. A host recording automation can treat
188
+ * the control as touched from here until `onChangeEnd`.
189
+ */
190
+ onChangeStart?: (value: number, source: ChangeSource) => void
191
+ /**
192
+ * Called when the change ends, with the value it ended on: on release, or
193
+ * `changeEndDelay` after the last wheel notch or arrow key.
194
+ */
195
+ onChangeEnd?: (value: number, source: ChangeSource) => void
196
+ /**
197
+ * How long after the last wheel notch or arrow key the change counts as
198
+ * over, in milliseconds. Neither has an event that says it is done.
199
+ *
200
+ * @default 500
201
+ */
202
+ changeEndDelay?: number
189
203
 
190
204
  /**
191
205
  * The knob renders exactly what you compose here; there is no default
@@ -203,6 +217,13 @@ export interface KnobProps {
203
217
  children: ReactNode
204
218
 
205
219
  style?: CSSProperties & CSSVariables<'knob-size'>
220
+
221
+ /**
222
+ * Receives `focus` and `blur`. They do nothing while the knob is disabled,
223
+ * where the element's own `focus()` would still focus it. `ref` reaches the
224
+ * root element itself.
225
+ */
226
+ actionsRef?: Ref<KnobMethods>
206
227
  }
207
228
 
208
229
  export interface KnobMethods {
@@ -218,7 +239,7 @@ type Props = KnobProps & Omit<ComponentPropsWithoutRef<'div'>, keyof KnobProps>
218
239
  */
219
240
  const WHEEL_OPTIONS = { requireFocus: true }
220
241
 
221
- export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
242
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
222
243
  (
223
244
  {
224
245
  value,
@@ -226,33 +247,35 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
226
247
  max,
227
248
  step = 1,
228
249
  scale = linearScale,
229
- defaultValue = min,
250
+ resetValue,
230
251
  startValue = min,
231
252
  size,
232
- externalStyles: _externalStyles,
253
+ dragCursor = 'grabbing',
233
254
  wheel = DEFAULT_WHEEL_OPTIONS,
234
255
  keyboard = DEFAULT_KEYBOARD_OPTIONS,
235
256
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
236
257
  pointerLock = false,
237
- enableDoubleClickDefault = true,
238
258
  disabled = false,
239
- readonly = false,
259
+ readOnly = false,
240
260
  angleRange = 270,
241
261
  onChange,
262
+ onChangeStart,
263
+ onChangeEnd,
264
+ changeEndDelay,
242
265
  onKeyDown,
243
266
  onPointerDown,
244
267
  onDoubleClick,
245
268
  className,
246
269
  style,
247
270
  children,
271
+ actionsRef,
248
272
  ...props
249
273
  }: Props,
250
274
  forwardedRef,
251
275
  ) => {
252
- const elmRef = useRef<HTMLElement | SVGElement>(null)
276
+ const elmRef = useRef<HTMLDivElement>(null)
253
277
 
254
- const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
255
- const inactive = disabled || readonly
278
+ const inactive = disabled || readOnly
256
279
 
257
280
  // --- internal functions ---
258
281
  const range: ValueRange = useMemo(
@@ -262,17 +285,29 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
262
285
 
263
286
  useCheckSteps({ component: 'Knob', range, keyboard, wheel })
264
287
 
288
+ const gesture = useChangeGesture(
289
+ value,
290
+ { onChangeStart, onChangeEnd, changeEndDelay },
291
+ inactive,
292
+ )
293
+ const change = useCallback(
294
+ (next: number) => {
295
+ gesture.changed(next)
296
+ onChange?.(next)
297
+ },
298
+ [gesture, onChange],
299
+ )
300
+
265
301
  const handleKeyDown = useCallback(
266
302
  (event: React.KeyboardEvent<HTMLOrSVGElement>) => {
267
303
  if (!keyboard || !onChange || inactive) return
268
- const key = event.key
269
- if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
270
- event.preventDefault()
271
- const direction = key === 'ArrowRight' || key === 'ArrowUp' ? 1 : -1
272
- onChange(applyDelta(value, direction, keyboard, range, event))
273
- }
304
+ const direction = arrowKeyDirection(event.key)
305
+ if (direction === null) return
306
+ event.preventDefault()
307
+ gesture.pulse('keyboard')
308
+ change(applyDelta(value, direction, keyboard, range, event))
274
309
  },
275
- [keyboard, onChange, inactive, value, range],
310
+ [keyboard, onChange, inactive, value, range, gesture, change],
276
311
  )
277
312
 
278
313
  // --- hooks ---
@@ -293,25 +328,35 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
293
328
  sensitivity: (state) =>
294
329
  selectModifier(dragSensitivity, state.event).value,
295
330
  threshold: 1,
296
- cursor: inactive ? undefined : externalStyles.cursor,
331
+ cursor: inactive ? undefined : dragCursor,
297
332
  pointerLock: inactive ? false : pointerLock,
298
333
  shouldStart: () => !inactive,
299
334
  onChange: (v) => {
300
335
  if (inactive) return
301
- onChange?.(v[1])
336
+ change(v[1])
337
+ },
338
+ onDragStart: () => {
339
+ if (!inactive) gesture.hold('pointer')
302
340
  },
341
+ onDragEnd: () => gesture.end(),
303
342
  })
304
343
 
305
344
  const wheelRefCallback = useWheel<HTMLElement>((event) => {
306
345
  if (!wheel || inactive) return
346
+ // A notch the knob does not read — a sideways scroll — is left to the
347
+ // page rather than swallowed.
348
+ const direction = wheelDirection(event)
349
+ if (direction === null) return
307
350
  event.preventDefault()
308
- if (!onChange || event.deltaY === 0) return
309
- onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range, event))
351
+ if (!onChange) return
352
+ gesture.pulse('wheel')
353
+ change(applyDelta(value, direction, wheel, range, event))
310
354
  }, WHEEL_OPTIONS)
311
355
 
312
356
  // Composed once, so React attaches the refs a single time instead of
313
357
  // detaching and re-attaching on every render.
314
- const rootRefCallback = useComposedRefs<HTMLElement | SVGElement>(
358
+ const rootRefCallback = useComposedRefs<HTMLDivElement>(
359
+ forwardedRef,
315
360
  elmRef,
316
361
  dragRefCallback,
317
362
  wheelRefCallback,
@@ -319,10 +364,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
319
364
 
320
365
  const context = useMemo(() => {
321
366
  const config = { value, min, max, step, scale, startValue, angleRange }
322
- return { ...config, ...calcAngles(config) }
367
+ return { ...config, ...knobAngles(config) }
323
368
  }, [value, min, max, step, scale, startValue, angleRange])
324
369
 
325
- useImperativeHandle(forwardedRef, () => {
370
+ useImperativeHandle(actionsRef, () => {
326
371
  return {
327
372
  focus() {
328
373
  if (!disabled) elmRef.current?.focus()
@@ -344,9 +389,9 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
344
389
  aria-valuemin={min}
345
390
  aria-valuemax={max}
346
391
  aria-disabled={disabled}
347
- aria-readonly={readonly}
392
+ aria-readonly={readOnly}
348
393
  data-disabled={disabled ? '' : undefined}
349
- data-readonly={readonly ? '' : undefined}
394
+ data-readonly={readOnly ? '' : undefined}
350
395
  data-dragging={dragging ? '' : undefined}
351
396
  style={
352
397
  {
@@ -356,8 +401,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
356
401
  }
357
402
  onPointerDown={onPointerDown}
358
403
  onDoubleClick={(event) => {
359
- if (!inactive && enableDoubleClickDefault && onChange) {
360
- onChange(defaultValue)
404
+ if (!inactive && resetValue !== null && onChange) {
405
+ gesture.instant('doubleClick', () =>
406
+ change(resetValue ?? startValue),
407
+ )
361
408
  }
362
409
  onDoubleClick?.(event)
363
410
  }}
@@ -7,9 +7,15 @@ import {
7
7
  useState,
8
8
  } from 'react'
9
9
 
10
+ import {
11
+ caretAtDecimalOffset,
12
+ caretDecimalOffset,
13
+ numberSpan,
14
+ } from '@tremolo-ui/dom/internal'
15
+
10
16
  import { useComposedRefs } from '../../compose-refs'
11
17
 
12
- import { useNumberInputContext } from './context'
18
+ import { useNumberInputContext, useNumberInputGesture } from './context'
13
19
 
14
20
  // The value belongs to `NumberInput.Root`, and the field is always text.
15
21
  type Props = Omit<
@@ -17,25 +23,6 @@ type Props = Omit<
17
23
  'type' | 'value' | 'defaultValue'
18
24
  >
19
25
 
20
- /** The leading number of the displayed text, whatever the format put around it. */
21
- const NUMBER_PREFIX = /^\s*-?[\d.,]*/
22
-
23
- const numberEnd = (text: string) =>
24
- text.match(NUMBER_PREFIX)?.[0].length ?? text.length
25
-
26
- /**
27
- * The index the caret is measured against: the decimal point, or where one
28
- * would go if the number has none.
29
- *
30
- * Measuring from an end instead would slide the caret across a digit whenever
31
- * the number changed length — `9.9` to `10.0` gains a character in front, `10`
32
- * to `9` loses one — which is exactly what stepping does.
33
- */
34
- const decimalAnchor = (text: string) => {
35
- const dot = text.indexOf('.')
36
- return dot === -1 ? numberEnd(text) : dot
37
- }
38
-
39
26
  /**
40
27
  * The text field of a `NumberInput`, and the only place the value can be typed.
41
28
  *
@@ -47,7 +34,7 @@ const decimalAnchor = (text: string) => {
47
34
  */
48
35
  export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
49
36
  function InputField(
50
- { className, style, onFocus, onBlur, onKeyDown, ...props },
37
+ { className, style, onChange, onFocus, onBlur, onKeyDown, ...props },
51
38
  forwardedRef,
52
39
  ) {
53
40
  const {
@@ -56,7 +43,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
56
43
  max,
57
44
  step,
58
45
  disabled,
59
- readonly,
46
+ readOnly,
60
47
  keyboard,
61
48
  selectOnFocus,
62
49
  unformatOnFocus,
@@ -71,6 +58,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
71
58
  inputRef,
72
59
  } = useNumberInputContext()
73
60
 
61
+ const gesture = useNumberInputGesture()
74
62
  const [focused, setFocused] = useState(false)
75
63
 
76
64
  // Only until the first keystroke: from then on the draft is the user's own
@@ -87,10 +75,8 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
87
75
  if (selectOnFocus === 'all') {
88
76
  input.setSelectionRange(0, input.value.length)
89
77
  } else {
90
- input.setSelectionRange(
91
- 0,
92
- input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
93
- )
78
+ const span = numberSpan(input.value)
79
+ if (span) input.setSelectionRange(span.start, span.end)
94
80
  }
95
81
  // Deliberately not re-run as the text changes: that would drag the
96
82
  // selection back over what the user is typing.
@@ -114,8 +100,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
114
100
  // caret has not moved either, since the key press was prevented.
115
101
  if (!input || input.value === pending.from) return
116
102
 
117
- const place = decimalAnchor(input.value) + pending.offset
118
- const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
103
+ const caret = caretAtDecimalOffset(input.value, pending.offset)
119
104
  input.setSelectionRange(caret, caret)
120
105
  })
121
106
 
@@ -135,11 +120,11 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
135
120
  role="spinbutton"
136
121
  value={shown}
137
122
  disabled={disabled}
138
- readOnly={readonly}
123
+ readOnly={readOnly}
139
124
  aria-disabled={disabled}
140
- aria-readonly={readonly}
125
+ aria-readonly={readOnly}
141
126
  data-disabled={disabled ? '' : undefined}
142
- data-readonly={readonly ? '' : undefined}
127
+ data-readonly={readOnly ? '' : undefined}
143
128
  aria-valuenow={value}
144
129
  aria-valuemin={min}
145
130
  aria-valuemax={max}
@@ -154,6 +139,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
154
139
  // stepped one no longer means anything.
155
140
  caretAfterStep.current = null
156
141
  setDraft(event.currentTarget.value)
142
+ onChange?.(event)
157
143
  }}
158
144
  onFocus={(event) => {
159
145
  setFocused(true)
@@ -174,7 +160,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
174
160
  } else if (
175
161
  keyboard &&
176
162
  !disabled &&
177
- !readonly &&
163
+ !readOnly &&
178
164
  (key === 'ArrowUp' || key === 'ArrowDown') &&
179
165
  // Arrow keys pick a candidate while an IME is converting. Stepping
180
166
  // the value there would fight the conversion, and moving the caret
@@ -185,10 +171,11 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
185
171
  const input = event.currentTarget
186
172
  if (keepCaretOnStep && input.selectionStart !== null) {
187
173
  caretAfterStep.current = {
188
- offset: input.selectionStart - decimalAnchor(input.value),
174
+ offset: caretDecimalOffset(input.value, input.selectionStart),
189
175
  from: input.value,
190
176
  }
191
177
  }
178
+ gesture?.key()
192
179
  nudge(key === 'ArrowUp' ? 1 : -1, keyboard, event)
193
180
  }
194
181
  onKeyDown?.(event)
@@ -1,136 +1,112 @@
1
- import { ComponentPropsWithoutRef, forwardRef, useMemo, useRef } from 'react'
1
+ import {
2
+ ComponentPropsWithoutRef,
3
+ forwardRef,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
2
9
 
3
10
  import {
4
- applyDelta,
5
- type InputEventOption,
6
- mapModifier,
7
- selectModifier,
8
- } from '@tremolo-ui/dom'
11
+ createStepperDrag,
12
+ type StepperDragInstance,
13
+ } from '@tremolo-ui/dom/internal'
9
14
 
10
15
  import { useComposedRefs } from '../../compose-refs'
11
- import { useDrag } from '../../hooks/useDrag'
12
16
 
13
- import { StepperProvider, useNumberInputContext } from './context'
17
+ import {
18
+ StepperProvider,
19
+ useNumberInputContext,
20
+ useNumberInputGesture,
21
+ } from './context'
14
22
 
15
23
  type Props = ComponentPropsWithoutRef<'div'>
16
24
 
17
25
  /**
18
26
  * The area the steppers sit in, and a drag handle in its own right: dragging it
19
- * up and down moves the value one `step` every `drag` pixels.
27
+ * up and down moves the value one `step` every `drag` pixels. The counting is
28
+ * `createStepperDrag` in the core.
20
29
  *
21
30
  * The drag lives here rather than on `InputField` because `createDrag` turns
22
31
  * off text selection on whatever element it is attached to.
23
32
  */
24
33
  export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
25
- function Stepper({ className, style, children, ...props }, forwardedRef) {
34
+ function Stepper(
35
+ { className, style, children, onPointerDown, ...props },
36
+ forwardedRef,
37
+ ) {
26
38
  const {
27
39
  value,
28
- step,
29
40
  disabled,
30
- readonly,
41
+ readOnly,
31
42
  drag,
32
43
  dragSensitivity,
33
44
  pointerLock,
45
+ dragCursor,
34
46
  rawRange,
35
47
  changeValue,
36
48
  } = useNumberInputContext()
37
- const inactive = disabled || readonly
38
-
39
- /**
40
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
41
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
42
- * the modifier map over rather than resolving it here is what keeps `step`
43
- * out of the pipeline for a modifier entry — naming one is a request to move
44
- * off the grid.
45
- */
46
- const dragOptions = useMemo(
47
- () =>
48
- mapModifier(dragSensitivity, (factor): InputEventOption => [
49
- 'raw',
50
- step * factor,
51
- ]),
52
- [dragSensitivity, step],
53
- )
49
+ // Only attached while it can do something: `createDrag` puts
50
+ // `touch-action: none` on the element, and a stepper that cannot be
51
+ // dragged has no reason to stop the page scrolling under a finger.
52
+ const enabled = drag !== null && !disabled && !readOnly
54
53
 
55
- /**
56
- * Set once the drag has actually moved the value, so that the press-and-hold
57
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
58
- * because it is read from inside the repeat rather than rendered.
59
- */
60
- const draggingRef = useRef(false)
61
- /**
62
- * Where the drag started, taken on the first move rather than on pointer
63
- * down: the steppers act on pointer down, so by then the value may already
64
- * have been nudged once, and the drag should carry on from there.
65
- */
66
- const originRef = useRef<{ y: number; value: number } | null>(null)
67
- /**
68
- * Which modifier the drag is currently counting at, and where the previous
69
- * event was — a key produces no pointer event of its own, so a change is
70
- * only seen on the next move and has to be dated back to the one before it.
71
- */
72
- const factorRef = useRef<number>(1)
73
- const previousYRef = useRef(0)
54
+ // See useDrag for why the node is held in state rather than a ref.
55
+ const [node, setNode] = useState<HTMLDivElement | null>(null)
56
+ const instanceRef = useRef<StepperDragInstance | null>(null)
57
+ const latest = useRef({ value, changeValue })
58
+ const options = {
59
+ range: rawRange,
60
+ pixels: drag ?? 1,
61
+ sensitivity: dragSensitivity,
62
+ pointerLock,
63
+ cursor: dragCursor,
64
+ }
65
+ const optionsRef = useRef(options)
74
66
 
75
- const dragRefCallback = useDrag<HTMLDivElement>({
76
- threshold: 1,
77
- cursor: inactive ? undefined : 'ns-resize',
78
- pointerLock: inactive ? false : pointerLock,
79
- onDragStart: (state) => {
80
- originRef.current = null
81
- draggingRef.current = false
82
- factorRef.current = selectModifier(dragSensitivity, state.event).value
83
- },
84
- onDrag: (_x, y, _dx, _dy, state) => {
85
- if (inactive || drag === null) return
86
- if (!originRef.current) {
87
- originRef.current = { y, value }
88
- previousYRef.current = y
89
- return
90
- }
67
+ // Runs after every render.
68
+ useEffect(() => {
69
+ latest.current = { value, changeValue }
70
+ optionsRef.current = options
71
+ instanceRef.current?.update(options)
72
+ })
91
73
 
92
- // Pressing or releasing the key mid-drag must not move the value, so the
93
- // travel so far is folded into the origin and measuring starts again
94
- // from the previous event: that event's own distance belongs to the new
95
- // sensitivity.
96
- const factor = selectModifier(dragSensitivity, state.event).value
97
- if (factor !== factorRef.current) {
98
- originRef.current = { y: previousYRef.current, value }
99
- factorRef.current = factor
100
- }
101
- previousYRef.current = y
74
+ useEffect(() => {
75
+ if (!node || !enabled) return
76
+ const instance = createStepperDrag(node, {
77
+ ...optionsRef.current,
78
+ getValue: () => latest.current.value,
79
+ onChange: (next) => latest.current.changeValue(next),
80
+ })
81
+ instanceRef.current = instance
82
+ return () => {
83
+ instanceRef.current = null
84
+ instance.destroy()
85
+ }
86
+ }, [node, enabled])
102
87
 
103
- // Dragging up raises the value, as on a knob.
104
- const steps = Math.round(-(y - originRef.current.y) / drag)
105
- if (steps === 0) return
106
- draggingRef.current = true
107
- // The same pipeline the wheel and the arrow keys use. Counting from where
108
- // the drag started keeps it from accumulating a rounding error.
109
- changeValue(
110
- applyDelta(
111
- originRef.current.value,
112
- steps,
113
- dragOptions,
114
- rawRange,
115
- state.event,
116
- ),
117
- )
118
- },
119
- onDragEnd: () => {
120
- draggingRef.current = false
121
- },
122
- })
88
+ const gesture = useNumberInputGesture()
89
+ const composedRef = useComposedRefs<HTMLDivElement>(forwardedRef, setNode)
123
90
 
124
- const composedRef = useComposedRefs<HTMLDivElement>(
125
- forwardedRef,
126
- drag !== null && !inactive ? dragRefCallback : undefined,
91
+ const context = useMemo(
92
+ () => ({ moved: () => instanceRef.current?.moved() ?? false }),
93
+ [],
127
94
  )
128
95
 
129
- const context = useMemo(() => ({ draggingRef }), [])
130
-
131
96
  return (
132
97
  <StepperProvider value={context}>
133
- <div ref={composedRef} className={className} style={style} {...props}>
98
+ <div
99
+ ref={composedRef}
100
+ className={className}
101
+ style={style}
102
+ onPointerDown={(event) => {
103
+ // A drag on the stepper is a press like any other: held from here
104
+ // until the pointer is released.
105
+ if (enabled) gesture?.press()
106
+ onPointerDown?.(event)
107
+ }}
108
+ {...props}
109
+ >
134
110
  {children}
135
111
  </div>
136
112
  </StepperProvider>