@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,13 +1,15 @@
1
1
  import {
2
2
  AriaAttributes,
3
3
  ComponentPropsWithoutRef,
4
+ CSSProperties,
4
5
  forwardRef,
6
+ Ref,
5
7
  useImperativeHandle,
6
8
  useRef,
7
- CSSProperties,
8
9
  } from 'react'
9
10
 
10
- import { toXY, type XYInput } from '@tremolo-ui/dom'
11
+ import { type XYInput } from '@tremolo-ui/dom'
12
+ import { toXY } from '@tremolo-ui/dom/internal'
11
13
 
12
14
  import { useCheckPlacement } from '../_util/Placement'
13
15
  import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
@@ -41,6 +43,12 @@ export interface XYPadThumbProps {
41
43
  'aria-valuetext'?: XYInput<AriaAttributes['aria-valuetext']>
42
44
 
43
45
  style?: CSSProperties & CSSVariables<'color' | 'translate'>
46
+
47
+ /**
48
+ * Receives `focus` and `blur` for the range inputs inside, which do nothing
49
+ * while the pad is disabled. `ref` reaches the thumb element itself.
50
+ */
51
+ actionsRef?: Ref<XYPadThumbMethods>
44
52
  }
45
53
 
46
54
  export interface XYPadThumbMethods {
@@ -51,7 +59,7 @@ export interface XYPadThumbMethods {
51
59
  type Props = XYPadThumbProps &
52
60
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadThumbProps>
53
61
 
54
- export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
62
+ export const Thumb = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
55
63
  function Thumb(
56
64
  {
57
65
  color,
@@ -62,6 +70,7 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
62
70
  'aria-labelledby': ariaLabelledby,
63
71
  'aria-describedby': ariaDescribedby,
64
72
  'aria-valuetext': ariaValuetext,
73
+ actionsRef,
65
74
  ...props
66
75
  },
67
76
  forwardedRef,
@@ -74,7 +83,7 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
74
83
  max,
75
84
  step,
76
85
  disabled,
77
- readonly,
86
+ readOnly,
78
87
  onChange,
79
88
  percent,
80
89
  thumbRef,
@@ -99,15 +108,16 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
99
108
  },
100
109
  })
101
110
 
102
- useImperativeHandle(forwardedRef, methods, [disabled])
111
+ useImperativeHandle(actionsRef, methods, [disabled])
103
112
  // Root focuses the thumb when a drag starts, wherever the user placed it.
104
113
  useImperativeHandle(thumbRef, methods, [disabled])
105
114
 
106
115
  return (
107
116
  <div
117
+ ref={forwardedRef}
108
118
  className={className}
109
119
  data-disabled={disabled ? '' : undefined}
110
- data-readonly={readonly ? '' : undefined}
120
+ data-readonly={readOnly ? '' : undefined}
111
121
  {...props}
112
122
  style={{
113
123
  ...{ '--color': color },
@@ -135,14 +145,14 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
135
145
  max={max[axis]}
136
146
  step={step[axis]}
137
147
  disabled={disabled}
138
- aria-readonly={readonly}
148
+ aria-readonly={readOnly}
139
149
  aria-orientation={axis === 0 ? 'horizontal' : 'vertical'}
140
150
  aria-label={labels[axis] ?? (axis === 0 ? 'x' : 'y')}
141
151
  aria-labelledby={labelledby[axis]}
142
152
  aria-describedby={describedby[axis]}
143
153
  aria-valuetext={valueText[axis]}
144
154
  onChange={(event) => {
145
- if (readonly) {
155
+ if (readOnly) {
146
156
  event.currentTarget.value = String(value[axis])
147
157
  return
148
158
  }
@@ -14,7 +14,7 @@ export type XYPadContextValue = {
14
14
  scale: XY<Scale>
15
15
  reverse: XY<boolean>
16
16
  disabled: boolean
17
- readonly: boolean
17
+ readOnly: boolean
18
18
  onChange?: (value: XY<number>) => void
19
19
 
20
20
  /**
@@ -11,25 +11,29 @@ import {
11
11
  } from 'react'
12
12
 
13
13
  import {
14
- applyDelta,
15
- arrowKeyMove,
16
14
  type AxisOptions,
15
+ type ChangeSource,
17
16
  DEFAULT_DRAG_SENSITIVITY,
18
17
  DEFAULT_KEYBOARD_OPTIONS,
19
18
  DEFAULT_WHEEL_OPTIONS,
20
19
  InputEventOption,
21
20
  ModifierState,
22
21
  type ModifierValue,
23
- selectModifier,
24
- toXY,
25
22
  valuePercent,
26
- wheelMove,
27
23
  type XY,
28
24
  type XYInput,
29
25
  } from '@tremolo-ui/dom'
26
+ import {
27
+ applyDelta,
28
+ arrowKeyMove,
29
+ selectModifier,
30
+ toXY,
31
+ wheelMove,
32
+ } from '@tremolo-ui/dom/internal'
30
33
  import { linearScale, type Scale } from '@tremolo-ui/functions'
31
34
 
32
35
  import { useComposedRefs } from '../../compose-refs'
36
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
33
37
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
34
38
  import { useDragValue } from '../../hooks/useDragValue'
35
39
  import { useWheel } from '../../hooks/useWheel'
@@ -38,10 +42,6 @@ import { Area } from './Area'
38
42
  import { XYPadProvider } from './context'
39
43
  import { Thumb, XYPadThumbMethods } from './Thumb'
40
44
 
41
- const defaultExternalStyles: XYPadProps['externalStyles'] = {
42
- cursor: 'pointer',
43
- }
44
-
45
45
  /**
46
46
  * Two-dimensional slider component.
47
47
  *
@@ -134,11 +134,9 @@ export interface XYPadProps {
134
134
  * The cursor to show while dragging. It is set on the dragged element, so it
135
135
  * stays while the pointer is outside the pad.
136
136
  *
137
- * @default { cursor: 'pointer' }
137
+ * @default 'pointer'
138
138
  */
139
- externalStyles?: {
140
- cursor?: CSSProperties['cursor']
141
- }
139
+ dragCursor?: CSSProperties['cursor']
142
140
 
143
141
  /**
144
142
  * Make the pad unchangeable and remove its thumb from the tab order.
@@ -149,14 +147,29 @@ export interface XYPadProps {
149
147
  * Make the pad unchangeable while leaving its thumb focusable.
150
148
  * The parts carry `data-readonly` while it is set.
151
149
  */
152
- readonly?: boolean
150
+ readOnly?: boolean
153
151
 
154
152
  /** Called with the new value when a drag, the wheel or an arrow key moves it. */
155
153
  onChange?: (value: XY<number>) => void
156
- /** Called when a drag starts, with the value where the area was pressed. */
157
- onDragStart?: (value: XY<number>) => void
158
- /** Called when the drag ends, with the value it ended on. */
159
- 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
160
173
 
161
174
  /**
162
175
  * The pad renders exactly what you compose here; there is no default
@@ -170,12 +183,18 @@ export interface XYPadProps {
170
183
  * </XYPad.Root>
171
184
  */
172
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>
173
193
  }
174
194
 
175
195
  export interface XYPadMethods {
176
196
  focus: () => void
177
197
  blur: () => void
178
- original: Ref<HTMLDivElement>
179
198
  }
180
199
 
181
200
  type Props = XYPadProps &
@@ -187,7 +206,7 @@ type Props = XYPadProps &
187
206
  */
188
207
  const WHEEL_OPTIONS = { requireFocus: true }
189
208
 
190
- export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
209
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
191
210
  (
192
211
  {
193
212
  value,
@@ -201,17 +220,19 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
201
220
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
202
221
  className,
203
222
  style,
204
- externalStyles: _externalStyles,
223
+ dragCursor = 'pointer',
205
224
  disabled = false,
206
- readonly = false,
225
+ readOnly = false,
207
226
  onChange,
208
- onDragStart,
209
- onDragEnd,
227
+ onChangeStart,
228
+ onChangeEnd,
229
+ changeEndDelay,
210
230
  onPointerDown,
211
231
  onKeyDown,
212
232
  onFocus,
213
233
  onBlur,
214
234
  children,
235
+ actionsRef,
215
236
  ...props
216
237
  }: Props,
217
238
  forwardedRef,
@@ -222,8 +243,20 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
222
243
  const thumbRef = useRef<XYPadThumbMethods>(null)
223
244
 
224
245
  // --- interpret props ---
225
- const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
226
- 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
+ )
227
260
 
228
261
  const min = useMemo(() => toXY(_min), [_min])
229
262
  const max = useMemo(() => toXY(_max), [_max])
@@ -306,9 +339,10 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
306
339
  event.preventDefault()
307
340
  if (!onChange || inactive || !keyboard) return
308
341
  const { axis: i, direction } = move
309
- onChange(nudge(i, reverse[i] ? -direction : direction, keyboard, event))
342
+ gesture.pulse('keyboard')
343
+ change(nudge(i, reverse[i] ? -direction : direction, keyboard, event))
310
344
  },
311
- [onChange, inactive, keyboard, reverse, nudge],
345
+ [onChange, inactive, keyboard, reverse, nudge, gesture, change],
312
346
  )
313
347
 
314
348
  // --- hooks ---
@@ -319,21 +353,18 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
319
353
  sensitivity: (state) =>
320
354
  selectModifier(dragSensitivity, state.event).value,
321
355
  updateOnPointerDown: true,
322
- cursor: inactive ? undefined : externalStyles.cursor,
356
+ cursor: inactive ? undefined : dragCursor,
323
357
  shouldStart: () => !inactive,
324
358
  onChange: (v) => {
325
359
  if (inactive) return
326
- onChange?.(v)
360
+ change(v)
327
361
  },
328
- onDragStart: (v) => {
362
+ onDragStart: () => {
329
363
  if (inactive) return
364
+ gesture.hold('pointer')
330
365
  thumbRef.current?.focus()
331
- onDragStart?.(v)
332
- },
333
- onDragEnd: (v) => {
334
- if (inactive) return
335
- onDragEnd?.(v)
336
366
  },
367
+ onDragEnd: () => gesture.end(),
337
368
  })
338
369
 
339
370
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
@@ -342,12 +373,14 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
342
373
  if (!move) return
343
374
  event.preventDefault()
344
375
  const { axis: i, direction } = move
345
- onChange(nudge(i, reverse[i] ? -direction : direction, wheel, event))
376
+ gesture.pulse('wheel')
377
+ change(nudge(i, reverse[i] ? -direction : direction, wheel, event))
346
378
  }, WHEEL_OPTIONS)
347
379
 
348
380
  // Composed once, so React attaches the refs a single time instead of
349
381
  // detaching and re-attaching on every render.
350
382
  const rootRefCallback = useComposedRefs<HTMLDivElement>(
383
+ forwardedRef,
351
384
  rootRef,
352
385
  dragRefCallback,
353
386
  wheelRefCallback,
@@ -362,7 +395,7 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
362
395
  scale,
363
396
  reverse,
364
397
  disabled,
365
- readonly,
398
+ readOnly,
366
399
  onChange,
367
400
  percent,
368
401
  areaRef,
@@ -376,13 +409,13 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
376
409
  scale,
377
410
  reverse,
378
411
  disabled,
379
- readonly,
412
+ readOnly,
380
413
  onChange,
381
414
  percent,
382
415
  ],
383
416
  )
384
417
 
385
- useImperativeHandle(forwardedRef, () => {
418
+ useImperativeHandle(actionsRef, () => {
386
419
  return {
387
420
  focus() {
388
421
  if (!disabled) thumbRef.current?.focus()
@@ -390,7 +423,6 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
390
423
  blur() {
391
424
  thumbRef.current?.blur()
392
425
  },
393
- original: rootRef,
394
426
  }
395
427
  }, [disabled])
396
428
 
@@ -403,7 +435,7 @@ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
403
435
  role="group"
404
436
  tabIndex={-1}
405
437
  data-disabled={disabled ? '' : undefined}
406
- data-readonly={readonly ? '' : undefined}
438
+ data-readonly={readOnly ? '' : undefined}
407
439
  style={style}
408
440
  onPointerDown={onPointerDown}
409
441
  onKeyDown={(event) => {
@@ -1,6 +1,6 @@
1
1
  import { ComponentPropsWithoutRef, forwardRef } from 'react'
2
2
 
3
- import { visuallyHiddenStyle } from '@tremolo-ui/dom'
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,6 +1,6 @@
1
1
  import { useEffect } from 'react'
2
2
 
3
- import { checkSteps, type CheckStepsOptions } from '@tremolo-ui/dom'
3
+ import { checkSteps, type CheckStepsOptions } from '@tremolo-ui/dom/internal'
4
4
 
5
5
  /**
6
6
  * Warn, in development only, when a key press or a wheel notch cannot produce
@@ -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
  }
@@ -2,52 +2,123 @@ import { useCallback, useEffect, useRef } from 'react'
2
2
 
3
3
  import { useCallbackRef } from './_internal/useCallbackRef'
4
4
 
5
- type Target = EventTarget | null | (() => EventTarget | null)
6
- type Options = boolean | AddEventListenerOptions
5
+ /**
6
+ * What {@link useEventListener} listens on. A function is called after every
7
+ * render, so it can return an element out of a ref; `null` listens on nothing.
8
+ */
9
+ export type UseEventListenerTarget =
10
+ | EventTarget
11
+ | null
12
+ | (() => EventTarget | null)
7
13
 
14
+ /**
15
+ * The options of `addEventListener`, as a boolean for `capture` or as an
16
+ * object. `signal` is left out: the hook already owns when the listener goes.
17
+ */
18
+ export type UseEventListenerOptions =
19
+ | boolean
20
+ | Pick<AddEventListenerOptions, 'capture' | 'once' | 'passive'>
21
+
22
+ interface Registration {
23
+ node: EventTarget
24
+ event: string
25
+ capture: boolean
26
+ once: boolean | undefined
27
+ passive: boolean | undefined
28
+ remove: VoidFunction
29
+ }
30
+
31
+ /**
32
+ * Listen to an event on a target for as long as the component is mounted.
33
+ *
34
+ * The handler is read on every event, and the target and the options are
35
+ * compared by what they resolve to, so all three can be written inline. The
36
+ * listener is re-attached only when the element, the event or one of the
37
+ * options actually changes.
38
+ *
39
+ * @returns a function that removes the listener. It stays removed until the
40
+ * target, the event or the options change.
41
+ */
8
42
  export function useEventListener<K extends keyof DocumentEventMap>(
9
- target: Target,
43
+ target: UseEventListenerTarget,
10
44
  event: K,
11
45
  handler: (event: DocumentEventMap[K]) => void,
12
- options?: Options,
46
+ options?: UseEventListenerOptions,
13
47
  ): VoidFunction
14
48
  export function useEventListener<K extends keyof WindowEventMap>(
15
- target: Target,
49
+ target: UseEventListenerTarget,
16
50
  event: K,
17
51
  handler: (event: WindowEventMap[K]) => void,
18
- options?: Options,
52
+ options?: UseEventListenerOptions,
19
53
  ): VoidFunction
20
54
  export function useEventListener<K extends keyof GlobalEventHandlersEventMap>(
21
- target: Target,
55
+ target: UseEventListenerTarget,
22
56
  event: K,
23
57
  handler: (event: GlobalEventHandlersEventMap[K]) => void,
24
- options?: Options,
58
+ options?: UseEventListenerOptions,
25
59
  ): VoidFunction
26
60
  export function useEventListener(
27
- target: Target,
61
+ target: UseEventListenerTarget,
28
62
  event: string,
29
63
  handler: (event: Event) => void,
30
- options?: Options,
64
+ options?: UseEventListenerOptions,
31
65
  ) {
32
66
  const listener = useCallbackRef(handler)
33
- const cleanupRef = useRef<VoidFunction>(() => {})
67
+ const registrationRef = useRef<Registration | null>(null)
68
+
69
+ const {
70
+ capture = false,
71
+ once,
72
+ passive,
73
+ } = typeof options === 'boolean' ? { capture: options } : (options ?? {})
34
74
 
75
+ // No dependency list: an inline target or options object is a new value on
76
+ // every render, so what decides a re-attach is the comparison below, not
77
+ // their identity.
35
78
  useEffect(() => {
36
- const node = typeof target === 'function' ? target() : (target ?? document)
79
+ const node = typeof target === 'function' ? target() : target
80
+ const current = registrationRef.current
37
81
 
82
+ if (
83
+ current &&
84
+ current.node === node &&
85
+ current.event === event &&
86
+ current.capture === capture &&
87
+ current.once === once &&
88
+ current.passive === passive
89
+ ) {
90
+ return
91
+ }
92
+
93
+ current?.remove()
94
+ registrationRef.current = null
38
95
  if (!node) return
39
96
 
40
- node.addEventListener(event, listener, options)
41
- const cleanup = () => {
42
- node.removeEventListener(event, listener, options)
97
+ const listenerOptions = { capture, once, passive }
98
+ node.addEventListener(event, listener, listenerOptions)
99
+ registrationRef.current = {
100
+ node,
101
+ event,
102
+ capture,
103
+ once,
104
+ passive,
105
+ remove: () => node.removeEventListener(event, listener, listenerOptions),
43
106
  }
44
- cleanupRef.current = cleanup
107
+ })
45
108
 
46
- return () => {
47
- cleanup()
48
- if (cleanupRef.current === cleanup) cleanupRef.current = () => {}
49
- }
50
- }, [event, target, options, listener])
109
+ useEffect(
110
+ () => () => {
111
+ registrationRef.current?.remove()
112
+ registrationRef.current = null
113
+ },
114
+ [],
115
+ )
51
116
 
52
- return useCallback(() => cleanupRef.current(), [])
117
+ // Kept registered, so a render with the same target does not attach again.
118
+ return useCallback(() => {
119
+ const current = registrationRef.current
120
+ if (!current) return
121
+ current.remove()
122
+ current.remove = () => {}
123
+ }, [])
53
124
  }