@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
@@ -11,33 +11,37 @@ import {
11
11
  } from 'react'
12
12
 
13
13
  import {
14
- applyDelta,
15
14
  type AxisOptions,
15
+ type ChangeSource,
16
+ DEFAULT_DRAG_SENSITIVITY,
17
+ DEFAULT_KEYBOARD_OPTIONS,
18
+ DEFAULT_WHEEL_OPTIONS,
16
19
  InputEventOption,
17
20
  ModifierState,
18
21
  type ModifierValue,
19
- selectModifier,
22
+ valuePercent,
23
+ type XY,
24
+ type XYInput,
20
25
  } from '@tremolo-ui/dom'
21
- import { linearScale, type Scale, toFixed } from '@tremolo-ui/functions'
26
+ import {
27
+ applyDelta,
28
+ arrowKeyMove,
29
+ selectModifier,
30
+ toXY,
31
+ wheelMove,
32
+ } from '@tremolo-ui/dom/internal'
33
+ import { linearScale, type Scale } from '@tremolo-ui/functions'
22
34
 
23
35
  import { useComposedRefs } from '../../compose-refs'
36
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
24
37
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
25
38
  import { useDragValue } from '../../hooks/useDragValue'
26
39
  import { useWheel } from '../../hooks/useWheel'
27
- import {
28
- DEFAULT_DRAG_SENSITIVITY,
29
- DEFAULT_KEYBOARD_OPTIONS,
30
- DEFAULT_WHEEL_OPTIONS,
31
- } from '../../input-event'
32
40
 
33
41
  import { Area } from './Area'
34
- import { toXY, XY, XYInput, XYPadProvider } from './context'
42
+ import { XYPadProvider } from './context'
35
43
  import { Thumb, XYPadThumbMethods } from './Thumb'
36
44
 
37
- const defaultExternalStyles: XYPadProps['externalStyles'] = {
38
- cursor: 'pointer',
39
- }
40
-
41
45
  /**
42
46
  * Two-dimensional slider component.
43
47
  *
@@ -130,11 +134,9 @@ export interface XYPadProps {
130
134
  * The cursor to show while dragging. It is set on the dragged element, so it
131
135
  * stays while the pointer is outside the pad.
132
136
  *
133
- * @default { cursor: 'pointer' }
137
+ * @default 'pointer'
134
138
  */
135
- externalStyles?: {
136
- cursor?: CSSProperties['cursor']
137
- }
139
+ dragCursor?: CSSProperties['cursor']
138
140
 
139
141
  /**
140
142
  * Make the pad unchangeable and remove its thumb from the tab order.
@@ -145,14 +147,29 @@ export interface XYPadProps {
145
147
  * Make the pad unchangeable while leaving its thumb focusable.
146
148
  * The parts carry `data-readonly` while it is set.
147
149
  */
148
- readonly?: boolean
150
+ readOnly?: boolean
149
151
 
150
152
  /** Called with the new value when a drag, the wheel or an arrow key moves it. */
151
153
  onChange?: (value: XY<number>) => void
152
- /** Called when a drag starts, with the value where the area was pressed. */
153
- onDragStart?: (value: XY<number>) => void
154
- /** Called when the drag ends, with the value it ended on. */
155
- onDragEnd?: (value: XY<number>) => void
154
+ /**
155
+ * Called when a change of the value starts — a press on the area, the first wheel notch
156
+ * or arrow key — with the value
157
+ * before it and what it is made with. A host recording automation can treat
158
+ * the control as touched from here until `onChangeEnd`.
159
+ */
160
+ onChangeStart?: (value: XY<number>, source: ChangeSource) => void
161
+ /**
162
+ * Called when the change ends, with the value it ended on: on release, or
163
+ * `changeEndDelay` after the last wheel notch or arrow key.
164
+ */
165
+ onChangeEnd?: (value: XY<number>, source: ChangeSource) => void
166
+ /**
167
+ * How long after the last wheel notch or arrow key the change counts as
168
+ * over, in milliseconds. Neither has an event that says it is done.
169
+ *
170
+ * @default 500
171
+ */
172
+ changeEndDelay?: number
156
173
 
157
174
  /**
158
175
  * The pad renders exactly what you compose here; there is no default
@@ -166,12 +183,18 @@ export interface XYPadProps {
166
183
  * </XYPad.Root>
167
184
  */
168
185
  children: ReactNode
186
+
187
+ /**
188
+ * Receives `focus` and `blur`, which act on the range inputs inside the thumb —
189
+ * the elements that take the focus — and do nothing while
190
+ * the pad is disabled. `ref` reaches the root element itself.
191
+ */
192
+ actionsRef?: Ref<XYPadMethods>
169
193
  }
170
194
 
171
195
  export interface XYPadMethods {
172
196
  focus: () => void
173
197
  blur: () => void
174
- original: Ref<HTMLDivElement>
175
198
  }
176
199
 
177
200
  type Props = XYPadProps &
@@ -183,7 +206,7 @@ type Props = XYPadProps &
183
206
  */
184
207
  const WHEEL_OPTIONS = { requireFocus: true }
185
208
 
186
- export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
209
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
187
210
  (
188
211
  {
189
212
  value,
@@ -197,17 +220,19 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
197
220
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
198
221
  className,
199
222
  style,
200
- externalStyles: _externalStyles,
223
+ dragCursor = 'pointer',
201
224
  disabled = false,
202
- readonly = false,
225
+ readOnly = false,
203
226
  onChange,
204
- onDragStart,
205
- onDragEnd,
227
+ onChangeStart,
228
+ onChangeEnd,
229
+ changeEndDelay,
206
230
  onPointerDown,
207
231
  onKeyDown,
208
232
  onFocus,
209
233
  onBlur,
210
234
  children,
235
+ actionsRef,
211
236
  ...props
212
237
  }: Props,
213
238
  forwardedRef,
@@ -218,8 +243,20 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
218
243
  const thumbRef = useRef<XYPadThumbMethods>(null)
219
244
 
220
245
  // --- interpret props ---
221
- const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
222
- const inactive = disabled || readonly
246
+ const inactive = disabled || readOnly
247
+
248
+ const gesture = useChangeGesture(
249
+ value,
250
+ { onChangeStart, onChangeEnd, changeEndDelay },
251
+ inactive,
252
+ )
253
+ const change = useCallback(
254
+ (next: XY<number>) => {
255
+ gesture.changed(next)
256
+ onChange?.(next)
257
+ },
258
+ [gesture, onChange],
259
+ )
223
260
 
224
261
  const min = useMemo(() => toXY(_min), [_min])
225
262
  const max = useMemo(() => toXY(_max), [_max])
@@ -227,14 +264,17 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
227
264
  const scale = useMemo(() => toXY(_scale), [_scale])
228
265
  const reverse = useMemo(() => toXY(_reverse), [_reverse])
229
266
 
230
- const percent = useMemo((): XY<number> => {
231
- const normalized = [0, 1].map((i) =>
232
- scale[i].normalize(value[i], min[i], max[i]),
233
- )
234
- return [0, 1].map((i) =>
235
- toFixed((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100),
236
- ) as XY<number>
237
- }, [value, min, max, scale, reverse])
267
+ const percent = useMemo(
268
+ (): XY<number> =>
269
+ [0, 1].map((i) =>
270
+ valuePercent(
271
+ value[i],
272
+ { min: min[i], max: max[i], scale: scale[i] },
273
+ reverse[i],
274
+ ),
275
+ ) as XY<number>,
276
+ [value, min, max, scale, reverse],
277
+ )
238
278
 
239
279
  // --- internal functions ---
240
280
  // `AxisOptions` extends `ValueRange`, so the same pair also describes the
@@ -291,23 +331,18 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
291
331
 
292
332
  const handleKeyDown = useCallback(
293
333
  (event: React.KeyboardEvent<HTMLDivElement>) => {
294
- const key = event.key
295
- if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
296
- return
297
-
298
334
  // The key picks the axis, whichever of the two inputs holds the
299
- // focus: the pad is one control to the person moving it, and the focus
300
- // lands on the x input, so reading the axis off the input would leave
301
- // the y axis with no keys at all.
302
- const i: 0 | 1 = key === 'ArrowRight' || key === 'ArrowLeft' ? 0 : 1
335
+ // focus: the focus lands on the x input, so reading the axis off the
336
+ // input would leave the y axis with no keys at all.
337
+ const move = arrowKeyMove(event.key)
338
+ if (!move) return
303
339
  event.preventDefault()
304
340
  if (!onChange || inactive || !keyboard) return
305
- let direction = 1
306
- if (key === 'ArrowLeft' || key === 'ArrowUp') direction *= -1
307
- if (reverse[i]) direction *= -1
308
- onChange(nudge(i, direction, keyboard, event))
341
+ const { axis: i, direction } = move
342
+ gesture.pulse('keyboard')
343
+ change(nudge(i, reverse[i] ? -direction : direction, keyboard, event))
309
344
  },
310
- [onChange, inactive, keyboard, reverse, nudge],
345
+ [onChange, inactive, keyboard, reverse, nudge, gesture, change],
311
346
  )
312
347
 
313
348
  // --- hooks ---
@@ -318,43 +353,34 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
318
353
  sensitivity: (state) =>
319
354
  selectModifier(dragSensitivity, state.event).value,
320
355
  updateOnPointerDown: true,
321
- cursor: inactive ? undefined : externalStyles.cursor,
356
+ cursor: inactive ? undefined : dragCursor,
322
357
  shouldStart: () => !inactive,
323
358
  onChange: (v) => {
324
359
  if (inactive) return
325
- onChange?.(v)
360
+ change(v)
326
361
  },
327
- onDragStart: (v) => {
362
+ onDragStart: () => {
328
363
  if (inactive) return
364
+ gesture.hold('pointer')
329
365
  thumbRef.current?.focus()
330
- onDragStart?.(v)
331
- },
332
- onDragEnd: (v) => {
333
- if (inactive) return
334
- onDragEnd?.(v)
335
366
  },
367
+ onDragEnd: () => gesture.end(),
336
368
  })
337
369
 
338
370
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
339
371
  if (!onChange || inactive || !wheel) return
340
- // Browsers turn shift+wheel into horizontal scrolling: `deltaY` comes
341
- // out empty and `deltaX` carries the movement. Reading whichever axis
342
- // moved keeps shift working as the x-axis modifier — and picks up a
343
- // trackpad's own horizontal gesture, which never had a modifier.
344
- const horizontal = event.deltaX !== 0
345
- const delta = horizontal ? event.deltaX : event.deltaY
346
- if (delta === 0) return
347
- const i: 0 | 1 = horizontal || event.shiftKey ? 0 : 1
372
+ const move = wheelMove(event)
373
+ if (!move) return
348
374
  event.preventDefault()
349
- let direction = 1
350
- if (delta < 0) direction *= -1
351
- if (reverse[i]) direction *= -1
352
- onChange(nudge(i, direction, wheel, event))
375
+ const { axis: i, direction } = move
376
+ gesture.pulse('wheel')
377
+ change(nudge(i, reverse[i] ? -direction : direction, wheel, event))
353
378
  }, WHEEL_OPTIONS)
354
379
 
355
380
  // Composed once, so React attaches the refs a single time instead of
356
381
  // detaching and re-attaching on every render.
357
382
  const rootRefCallback = useComposedRefs<HTMLDivElement>(
383
+ forwardedRef,
358
384
  rootRef,
359
385
  dragRefCallback,
360
386
  wheelRefCallback,
@@ -369,7 +395,7 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
369
395
  scale,
370
396
  reverse,
371
397
  disabled,
372
- readonly,
398
+ readOnly,
373
399
  onChange,
374
400
  percent,
375
401
  areaRef,
@@ -383,13 +409,13 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
383
409
  scale,
384
410
  reverse,
385
411
  disabled,
386
- readonly,
412
+ readOnly,
387
413
  onChange,
388
414
  percent,
389
415
  ],
390
416
  )
391
417
 
392
- useImperativeHandle(forwardedRef, () => {
418
+ useImperativeHandle(actionsRef, () => {
393
419
  return {
394
420
  focus() {
395
421
  if (!disabled) thumbRef.current?.focus()
@@ -397,7 +423,6 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
397
423
  blur() {
398
424
  thumbRef.current?.blur()
399
425
  },
400
- original: rootRef,
401
426
  }
402
427
  }, [disabled])
403
428
 
@@ -410,7 +435,7 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
410
435
  role="group"
411
436
  tabIndex={-1}
412
437
  data-disabled={disabled ? '' : undefined}
413
- data-readonly={readonly ? '' : undefined}
438
+ data-readonly={readOnly ? '' : undefined}
414
439
  style={style}
415
440
  onPointerDown={onPointerDown}
416
441
  onKeyDown={(event) => {
@@ -444,10 +469,4 @@ export const XYPad = {
444
469
  }
445
470
 
446
471
  export { type XYPadThumbProps, type XYPadThumbMethods } from './Thumb'
447
- export { type XYPadAreaProps } from './Area'
448
- export {
449
- useXYPadContext,
450
- type XYPadContextValue,
451
- type XY,
452
- type XYInput,
453
- } from './context'
472
+ export { useXYPadContext, type XYPadContextValue } from './context'
@@ -1,6 +1,6 @@
1
1
  import { ComponentPropsWithoutRef, forwardRef } from 'react'
2
2
 
3
- import { visuallyHiddenStyle } from './visually-hidden'
3
+ import { visuallyHiddenStyle } from '@tremolo-ui/dom/internal'
4
4
 
5
5
  type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type' | 'style'>
6
6
 
@@ -0,0 +1,93 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useInsertionEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
9
+
10
+ import {
11
+ createChangeGesture,
12
+ type ChangeGestureInstance,
13
+ type ChangeSource,
14
+ } from '@tremolo-ui/dom'
15
+
16
+ export interface ChangeGestureProps<V> {
17
+ onChangeStart?: (value: V, source: ChangeSource) => void
18
+ onChangeEnd?: (value: V, source: ChangeSource) => void
19
+ changeEndDelay?: number
20
+ }
21
+
22
+ export type ChangeGesture<V> = Pick<
23
+ ChangeGestureInstance,
24
+ 'hold' | 'pulse' | 'instant' | 'end' | 'active'
25
+ > & {
26
+ /**
27
+ * Record a value the control has just reported through `onChange`, for
28
+ * `onChangeEnd` to give. The prop lags behind until the parent renders, and
29
+ * a drag ends in the same event as its last change.
30
+ */
31
+ changed: (value: V) => void
32
+ }
33
+
34
+ /**
35
+ * The core's change gesture, reporting the control's value to
36
+ * `onChangeStart` / `onChangeEnd`. The start gets the value before the change,
37
+ * the end the last one reported.
38
+ *
39
+ * Internal
40
+ * @private
41
+ */
42
+ export function useChangeGesture<V>(
43
+ value: V,
44
+ { onChangeStart, onChangeEnd, changeEndDelay }: ChangeGestureProps<V>,
45
+ inactive: boolean,
46
+ ): ChangeGesture<V> {
47
+ const [instance] = useState(() => createChangeGesture())
48
+ const latest = useRef({ value, onChangeStart, onChangeEnd })
49
+ // What `onChangeEnd` gives: the value at the start, then each one reported.
50
+ const lastValue = useRef(value)
51
+
52
+ useInsertionEffect(() => {
53
+ latest.current = { value, onChangeStart, onChangeEnd }
54
+ })
55
+
56
+ // The callbacks are handed over here rather than at creation: they read the
57
+ // refs, which belong outside render. Nothing can start a gesture before the
58
+ // element is mounted, so they are in place by the first event.
59
+ useEffect(() => {
60
+ instance.update({
61
+ endDelay: changeEndDelay,
62
+ onStart: (source) => {
63
+ lastValue.current = latest.current.value
64
+ latest.current.onChangeStart?.(latest.current.value, source)
65
+ },
66
+ onEnd: (source) =>
67
+ latest.current.onChangeEnd?.(lastValue.current, source),
68
+ })
69
+ }, [instance, changeEndDelay])
70
+
71
+ // A control that turns inactive mid-gesture, or goes away, should not leave
72
+ // a host thinking it is still being touched.
73
+ useEffect(() => {
74
+ if (inactive) instance.end()
75
+ }, [instance, inactive])
76
+ useEffect(() => () => instance.end(), [instance])
77
+
78
+ const changed = useCallback((next: V) => {
79
+ lastValue.current = next
80
+ }, [])
81
+
82
+ return useMemo(
83
+ () => ({
84
+ hold: instance.hold,
85
+ pulse: instance.pulse,
86
+ instant: instance.instant,
87
+ end: instance.end,
88
+ active: instance.active,
89
+ changed,
90
+ }),
91
+ [instance, changed],
92
+ )
93
+ }
@@ -1,162 +1,25 @@
1
1
  import { useEffect } from 'react'
2
2
 
3
- import {
4
- applyDelta,
5
- type InputEventOption,
6
- type ModifierState,
7
- type ModifierValue,
8
- } from '@tremolo-ui/dom'
9
- import { linearScale, type ValueRange } from '@tremolo-ui/functions'
10
-
11
- /** Positions probed across the travel. The ends are left out so that the
12
- * clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
13
- const PROBES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
14
-
15
- const NONE: ModifierState = {
16
- shiftKey: false,
17
- altKey: false,
18
- ctrlKey: false,
19
- metaKey: false,
20
- }
21
-
22
- const MODIFIER_STATE: Record<string, ModifierState> = {
23
- shift: { ...NONE, shiftKey: true },
24
- alt: { ...NONE, altKey: true },
25
- ctrl: { ...NONE, ctrlKey: true },
26
- meta: { ...NONE, metaKey: true },
27
- }
28
-
29
- /** Each entry of a modifier-aware input option, as a name and held keys. */
30
- function entries(
31
- options: ModifierValue<InputEventOption>,
32
- ): { name: string; modifiers: ModifierState }[] {
33
- if (Array.isArray(options)) return [{ name: '', modifiers: NONE }]
34
- return Object.keys(options).map((key) => ({
35
- name: key === 'default' ? '' : key,
36
- modifiers: MODIFIER_STATE[key] ?? NONE,
37
- }))
38
- }
39
-
40
- interface Outcome {
41
- /** The press changed the value at least once across the travel. */
42
- moved: boolean
43
- /** The change reached the displayed text at least once. */
44
- visible: boolean
45
- }
46
-
47
- /**
48
- * Press every entry of `options` at nine points along the travel and report
49
- * whether anything came of it.
50
- *
51
- * Run against `applyDelta` itself rather than against a reading of `step`:
52
- * the whole point is that the amount, the step and the scale interact, and
53
- * the pipeline is the only thing that knows how.
54
- */
55
- function probe(
56
- options: ModifierValue<InputEventOption>,
57
- range: ValueRange,
58
- format?: (value: number) => string,
59
- ): Outcome {
60
- const { min, max, scale = linearScale } = range
61
- const outcome: Outcome = { moved: false, visible: false }
62
-
63
- for (const { modifiers } of entries(options)) {
64
- for (const position of PROBES) {
65
- const value = scale.denormalize(position, min, max)
66
- const up = applyDelta(value, 1, options, range, modifiers)
67
- const down = applyDelta(value, -1, options, range, modifiers)
68
- if (up !== value || down !== value) outcome.moved = true
69
- if (!format) continue
70
- const shown = format(value)
71
- if (format(up) !== shown || format(down) !== shown) outcome.visible = true
72
- }
73
- }
74
-
75
- return outcome
76
- }
77
-
78
- export interface CheckStepsOptions {
79
- /** The component, for the message. */
80
- component: string
81
- /** The axis, for a component that has more than one. */
82
- axis?: string
83
- /**
84
- * The range to probe, or `null` to check nothing. An unbounded input has no
85
- * travel to sample, so `NumberInput` passes `null` when `min` and `max` are
86
- * not both there.
87
- */
88
- range: ValueRange | null
89
- keyboard?: ModifierValue<InputEventOption> | null
90
- wheel?: ModifierValue<InputEventOption> | null
91
- /**
92
- * How the value is displayed, where the component shows one. Called with
93
- * probe values only, and only in development.
94
- */
95
- format?: (value: number) => string
96
- }
3
+ import { checkSteps, type CheckStepsOptions } from '@tremolo-ui/dom/internal'
97
4
 
98
5
  /**
99
6
  * Warn, in development only, when a key press or a wheel notch cannot produce
100
- * a change the user can see.
101
- *
102
- * Two settings that are each fine on their own can cancel out, and nothing
103
- * fails when they do — the control simply sits there:
104
- *
105
- * - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
106
- * `step={1}` rounds every press straight back to where it started
107
- * - **the display coarser than the amount.** A `format` showing two decimals
108
- * of a kHz value cannot show a press worth 1 Hz
109
- *
110
- * The second is only reported when the press is invisible at *every* point
111
- * along the travel. A display that rounds is a deliberate choice and is
112
- * normally right — it is being too coarse everywhere that makes it a mistake.
7
+ * a change the user can see. The probing is `checkSteps` in the core.
113
8
  *
114
9
  * @internal
115
10
  */
116
- export function useCheckSteps({
117
- component,
118
- axis,
119
- range,
120
- keyboard,
121
- wheel,
122
- format,
123
- }: CheckStepsOptions) {
11
+ export function useCheckSteps(options: CheckStepsOptions) {
12
+ const { component, axis, range, keyboard, wheel } = options
124
13
  const { min, max, step, scale } = range ?? {}
125
- const where = axis ? `${component} (${axis})` : component
126
14
 
127
15
  useEffect(() => {
128
16
  try {
129
17
  // Inline and first, so a bundler folds the comparison and drops the
130
- // whole block — the probing and the message strings with it. See
131
- // `useCheckPlacement` for why it cannot go through a helper, and why
132
- // it is wrapped.
18
+ // whole block — and with it the only call to `checkSteps`, which then
19
+ // falls out of the bundle too. See `useCheckPlacement` for why it
20
+ // cannot go through a helper, and why it is wrapped.
133
21
  if (process.env.NODE_ENV === 'production') return
134
- if (!range || min === undefined || max === undefined || !(min < max)) {
135
- return
136
- }
137
-
138
- for (const [name, options] of [
139
- ['keyboard', keyboard],
140
- ['wheel', wheel],
141
- ] as const) {
142
- if (!options) continue
143
- const { moved, visible } = probe(options, range, format)
144
- if (!moved) {
145
- console.warn(
146
- `[tremolo-ui] ${where}: \`${name}\` cannot move the value.` +
147
- (step !== undefined
148
- ? ` Each press is smaller than \`step\` (${step}), so it rounds`
149
- : ' Each press rounds') +
150
- ' straight back to where it started.',
151
- )
152
- } else if (format && !visible) {
153
- console.warn(
154
- `[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\`` +
155
- ' shows the same text before and after, everywhere in the range.' +
156
- ' The display is too coarse for it to be seen.',
157
- )
158
- }
159
- }
22
+ for (const warning of checkSteps(options)) console.warn(warning)
160
23
  } catch {
161
24
  // No bundler substituted NODE_ENV, so a production build cannot be told
162
25
  // from a development one. Say nothing rather than break.
@@ -165,5 +28,5 @@ export function useCheckSteps({
165
28
  // on what actually decides the outcome. `format` is left out on purpose:
166
29
  // an inline arrow function would make this run every render.
167
30
  // oxlint-disable-next-line react-hooks/exhaustive-deps
168
- }, [where, min, max, step, scale, keyboard, wheel])
31
+ }, [component, axis, min, max, step, scale, keyboard, wheel])
169
32
  }
@@ -1,28 +1,50 @@
1
- import { DependencyList, useEffect, useRef } from 'react'
1
+ import { useEffect } from 'react'
2
2
 
3
3
  import { useCallbackRef } from './_internal/useCallbackRef'
4
4
 
5
+ export interface UseAnimationFrameOptions {
6
+ /**
7
+ * Stop the loop. Turning it back on starts a new one, whose first frame has
8
+ * a `deltaTime` of `0`.
9
+ * @default false
10
+ */
11
+ disabled?: boolean
12
+ }
13
+
14
+ /**
15
+ * Call `callback` on every animation frame for as long as the component is
16
+ * mounted.
17
+ *
18
+ * The callback is read on every frame, so it can be written inline and see
19
+ * the latest render without restarting the loop.
20
+ *
21
+ * @param callback receives the frame's timestamp and the milliseconds since
22
+ * the previous frame, which is `0` on the first one
23
+ */
5
24
  export function useAnimationFrame(
6
- callback = () => {},
7
- deps: DependencyList = [],
25
+ callback: (timestamp: DOMHighResTimeStamp, deltaTime: number) => void,
26
+ { disabled = false }: UseAnimationFrameOptions = {},
8
27
  ) {
9
- const reqIdRef = useRef(-1)
10
28
  // Read through a ref rather than depended on: `useAnimationFrame(() => ...)`
11
29
  // is a new function on every render, and a callback that renders would then
12
- // cancel and re-schedule its own loop on every frame. What restarts the loop
13
- // is the caller's `deps`, and nothing else.
30
+ // cancel and re-schedule its own loop on every frame.
14
31
  const runCallback = useCallbackRef(callback)
15
32
 
16
33
  useEffect(() => {
17
- // Kept inside the effect: as a `useCallback` the loop would have to
18
- // reference itself before it is declared, which the compiler rules reject.
19
- const loop = () => {
20
- reqIdRef.current = requestAnimationFrame(loop)
21
- runCallback()
34
+ if (disabled) return
35
+
36
+ let frameId = 0
37
+ let previous: DOMHighResTimeStamp | undefined
38
+
39
+ // Scheduled before calling back, so that a slow callback does not delay
40
+ // the next request.
41
+ const loop = (timestamp: DOMHighResTimeStamp) => {
42
+ frameId = requestAnimationFrame(loop)
43
+ runCallback(timestamp, previous === undefined ? 0 : timestamp - previous)
44
+ previous = timestamp
22
45
  }
23
46
 
24
- reqIdRef.current = requestAnimationFrame(loop)
25
- return () => cancelAnimationFrame(reqIdRef.current)
26
- // oxlint-disable-next-line react-hooks/exhaustive-deps
27
- }, [runCallback, ...deps])
47
+ frameId = requestAnimationFrame(loop)
48
+ return () => cancelAnimationFrame(frameId)
49
+ }, [runCallback, disabled])
28
50
  }