@tremolo-ui/react 0.4.0 → 0.6.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 (90) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +2272 -1650
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1461 -475
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1461 -475
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2258 -1648
  16. package/dist/index.js.map +1 -1
  17. package/package.json +32 -31
  18. package/src/components/AnimationCanvas/index.tsx +162 -194
  19. package/src/components/Knob/ActiveLine.tsx +9 -8
  20. package/src/components/Knob/InactiveLine.tsx +11 -14
  21. package/src/components/Knob/SVGRoot.tsx +10 -17
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +73 -89
  24. package/src/components/Knob/index.tsx +227 -105
  25. package/src/components/NumberInput/DecrementStepper.tsx +21 -50
  26. package/src/components/NumberInput/IncrementStepper.tsx +22 -50
  27. package/src/components/NumberInput/InputField.tsx +206 -0
  28. package/src/components/NumberInput/Stepper.tsx +150 -21
  29. package/src/components/NumberInput/StepperButton.tsx +95 -0
  30. package/src/components/NumberInput/context.tsx +104 -149
  31. package/src/components/NumberInput/index.tsx +461 -110
  32. package/src/components/Piano/index.tsx +413 -263
  33. package/src/components/Piano/keyboard-shortcuts.ts +61 -0
  34. package/src/components/PointsEditor/Background.tsx +19 -4
  35. package/src/components/PointsEditor/Container.tsx +92 -22
  36. package/src/components/PointsEditor/Point.tsx +293 -53
  37. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  38. package/src/components/PointsEditor/context.tsx +97 -75
  39. package/src/components/PointsEditor/index.tsx +497 -62
  40. package/src/components/Slider/Marks.tsx +84 -0
  41. package/src/components/Slider/MarksOption.tsx +115 -0
  42. package/src/components/Slider/Thumb.tsx +99 -54
  43. package/src/components/Slider/Track.tsx +73 -62
  44. package/src/components/Slider/context.tsx +40 -53
  45. package/src/components/Slider/index.tsx +237 -190
  46. package/src/components/Slider/type.ts +27 -9
  47. package/src/components/XYPad/Area.tsx +52 -33
  48. package/src/components/XYPad/Thumb.tsx +121 -60
  49. package/src/components/XYPad/context.tsx +49 -0
  50. package/src/components/XYPad/index.tsx +338 -246
  51. package/src/components/_util/Placement.tsx +68 -0
  52. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  53. package/src/components/_util/css-length.ts +13 -0
  54. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  55. package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
  56. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  57. package/src/hooks/useAnimationFrame.ts +17 -7
  58. package/src/hooks/useDrag.ts +39 -9
  59. package/src/hooks/useDragValue.ts +194 -0
  60. package/src/hooks/useEventListener.ts +11 -7
  61. package/src/hooks/useLongPress.ts +42 -8
  62. package/src/hooks/useMIDIAccess.ts +82 -11
  63. package/src/hooks/useMIDIInput.ts +45 -13
  64. package/src/hooks/useMIDIMessage.ts +23 -5
  65. package/src/hooks/useWheel.ts +29 -9
  66. package/src/index.ts +50 -27
  67. package/src/input-event.ts +32 -0
  68. package/dist/index.css +0 -536
  69. package/src/Globals.d.ts +0 -3
  70. package/src/components/AnimationCanvas/canvas.ts +0 -46
  71. package/src/components/Knob/index.css +0 -77
  72. package/src/components/NumberInput/InternalInput.tsx +0 -216
  73. package/src/components/NumberInput/index.css +0 -185
  74. package/src/components/NumberInput/type.ts +0 -58
  75. package/src/components/Piano/KeyLabel.tsx +0 -51
  76. package/src/components/Piano/context.tsx +0 -73
  77. package/src/components/Piano/index.css +0 -90
  78. package/src/components/Piano/key.tsx +0 -205
  79. package/src/components/Piano/keyboardShortcuts.ts +0 -31
  80. package/src/components/PointsEditor/index.css +0 -30
  81. package/src/components/Slider/Scale.tsx +0 -52
  82. package/src/components/Slider/ScaleOption.tsx +0 -103
  83. package/src/components/Slider/index.css +0 -112
  84. package/src/components/XYPad/index.css +0 -48
  85. package/src/components/_util/index.ts +0 -28
  86. package/src/components/_util/type.ts +0 -1
  87. package/src/hooks/useDragWithElement.ts +0 -100
  88. package/src/hooks/usePianoDrag.ts +0 -75
  89. package/src/hooks/useRefCallbackEvent.ts +0 -34
  90. package/src/styles/global.css +0 -19
@@ -1,8 +1,8 @@
1
- import clsx from 'clsx'
2
- import React, {
1
+ import {
3
2
  ComponentPropsWithoutRef,
3
+ CSSProperties,
4
4
  forwardRef,
5
- ReactElement,
5
+ ReactNode,
6
6
  Ref,
7
7
  useCallback,
8
8
  useImperativeHandle,
@@ -10,77 +10,164 @@ import React, {
10
10
  useRef,
11
11
  } from 'react'
12
12
 
13
+ import type { AxisOptions } from '@tremolo-ui/dom'
13
14
  import {
14
- clamp,
15
- normalizeValue,
16
- rawValue,
17
- stepValue,
15
+ applyDelta,
16
+ linearScale,
18
17
  toFixed,
19
18
  InputEventOption,
20
- styleHelper,
19
+ ModifierState,
20
+ type ModifierValue,
21
+ selectModifier,
22
+ type Scale,
21
23
  } from '@tremolo-ui/functions'
22
24
 
23
- import { useDragWithElement } from '../../hooks/useDragWithElement'
25
+ import { useComposedRefs } from '../../compose-refs'
26
+ import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
27
+ import { useDragValue } from '../../hooks/useDragValue'
24
28
  import { useWheel } from '../../hooks/useWheel'
25
- import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
26
- import { composeRefs } from '../_util/composeRefs'
27
-
28
- import { Area, XYPadAreaProps } from './Area'
29
- import { Thumb, XYPadThumbMethods, XYPadThumbProps } from './Thumb'
30
-
31
- const defaultValueOptions = {
32
- skew: 1,
33
- step: 1,
34
- reverse: false,
35
- wheel: ['raw', 1] as InputEventOption,
36
- keyboard: ['raw', 1] as InputEventOption,
37
- }
29
+ import {
30
+ DEFAULT_DRAG_SENSITIVITY,
31
+ DEFAULT_KEYBOARD_OPTIONS,
32
+ DEFAULT_WHEEL_OPTIONS,
33
+ } from '../../input-event'
34
+
35
+ import { Area } from './Area'
36
+ import { toXY, XY, XYInput, XYPadProvider } from './context'
37
+ import { Thumb, XYPadThumbMethods } from './Thumb'
38
38
 
39
39
  const defaultExternalStyles: XYPadProps['externalStyles'] = {
40
- userSelectNone: true,
41
40
  cursor: 'pointer',
42
41
  }
43
42
 
44
43
  /**
45
44
  * Two-dimensional slider component.
45
+ *
46
+ * The per-axis settings mirror `Slider`, given as `[x, y]` tuples. A plain
47
+ * value applies to both axes.
46
48
  */
47
- export interface ValueOptions {
48
- // required
49
- value: number
50
- min: number
51
- max: number
52
-
53
- // optional
54
- step?: number
55
- skew?: number
56
- reverse?: boolean
49
+ export interface XYPadProps {
50
+ /** The current value as `[x, y]`. The pad shows only this, so update it from `onChange`. */
51
+ value: XY<number>
52
+ /** The value at the start of each axis, as `[x, y]` or one number for both. */
53
+ min: XYInput<number>
54
+ /** The value at the end of each axis, as `[x, y]` or one number for both. */
55
+ max: XYInput<number>
56
+
57
57
  /**
58
- * wheel control option
58
+ * Granularity of each axis, as `[x, y]` or one number for both. A drag, the
59
+ * wheel and the arrow keys snap the value to multiples of it.
60
+ *
61
+ * @default 1
59
62
  */
60
- wheel?: InputEventOption | null
63
+ step?: XYInput<number>
61
64
  /**
62
- * keyboard control option
65
+ * How the value of each axis is distributed across the travel, as `[x, y]`
66
+ * or one scale for both.
67
+ *
68
+ * Pick one of the scales from `@tremolo-ui/functions`: `linearScale`,
69
+ * `exponentialScale`, `curveScale(n)`, `symmetricSkewScale(n)`, or
70
+ * `skewScale(n)` for a value that has to match a JUCE parameter.
71
+ *
72
+ * @default linearScale
63
73
  */
64
- keyboard?: InputEventOption | null
65
- }
74
+ scale?: XYInput<Scale>
75
+ /**
76
+ * Grow an axis the other way, as `[x, y]` or one for both. By default x grows
77
+ * rightwards and y downwards. The arrow keys follow the direction on screen.
78
+ *
79
+ * @default false
80
+ */
81
+ reverse?: XYInput<boolean>
66
82
 
67
- export interface XYPadProps {
68
- x: ValueOptions
69
- y: ValueOptions
83
+ /**
84
+ * How much one notch of the wheel moves the value. It only acts while the
85
+ * focus is inside, so that scrolling the page past the pad leaves it alone.
86
+ * Scrolling sideways, or with shift held, moves x; otherwise it moves y.
87
+ *
88
+ * `['raw', n]` moves the value by `n`, and `['normalized', n]` by `n` of the
89
+ * range of that axis. The result is snapped to `step`, except for an amount
90
+ * set on a modifier key (`{ default: …, alt: … }`). `null` turns the wheel
91
+ * off.
92
+ *
93
+ * @default ['raw', 1]
94
+ */
95
+ wheel?: ModifierValue<InputEventOption> | null
96
+ /**
97
+ * How much a drag moves the value, per modifier key.
98
+ *
99
+ * `1` is the pointer position itself, which is what a drag normally is here.
100
+ * **Anything else turns the drag relative**: `0.1` makes the same movement
101
+ * cover a tenth of the travel, so the value stops following the pointer and
102
+ * starts moving a tenth as fast. Shift is bound to `0.1` by default, to
103
+ * match what it does on the arrow keys.
104
+ *
105
+ * Pressing or releasing the key mid-drag does not disturb the value: the
106
+ * travel so far is kept and the new sensitivity applies from there. **The
107
+ * pointer and the value stay apart for the rest of the drag** — snapping
108
+ * them back together on release would move the value nobody asked to move.
109
+ *
110
+ * @default { default: 1, shift: 0.1 }
111
+ */
112
+ dragSensitivity?: ModifierValue<number>
70
113
 
114
+ /**
115
+ * How much one arrow key press moves the value. Left and right move x, up
116
+ * and down move y.
117
+ *
118
+ * `['raw', n]` moves the value by `n`, and `['normalized', n]` by `n` of the
119
+ * range of that axis. The result is snapped to `step`, except for an amount
120
+ * set on a modifier key, which is what lets shift move off the grid. `null`
121
+ * turns the arrow keys off.
122
+ *
123
+ * The default moves by 1, and by 0.1 with shift. With a `step` above 1, raise
124
+ * the amount to match: 1 would round straight back to where it started, and
125
+ * a development build warns about it.
126
+ *
127
+ * @default { default: ['raw', 1], shift: ['raw', 0.1] }
128
+ */
129
+ keyboard?: ModifierValue<InputEventOption> | null
130
+
131
+ /**
132
+ * The cursor to show while dragging. It is set on the dragged element, so it
133
+ * stays while the pointer is outside the pad.
134
+ *
135
+ * @default { cursor: 'pointer' }
136
+ */
71
137
  externalStyles?: {
72
- userSelectNone?: boolean
73
- cursor?: Cursor
138
+ cursor?: CSSProperties['cursor']
74
139
  }
75
140
 
141
+ /**
142
+ * Make the pad unchangeable and remove its thumb from the tab order.
143
+ * The parts carry `data-disabled` while it is set.
144
+ */
76
145
  disabled?: boolean
146
+ /**
147
+ * Make the pad unchangeable while leaving its thumb focusable.
148
+ * The parts carry `data-readonly` while it is set.
149
+ */
77
150
  readonly?: boolean
78
151
 
79
- onChange?: (valueX: number, valueY: number) => void
80
- onDragStart?: (valueX: number, valueY: number) => void
81
- onDragEnd?: (valueX: number, valueY: number) => void
82
- /** \<XYPadThumb /> | \<XYPadArea /> */
83
- children?: ReactElement | ReactElement[]
152
+ /** Called with the new value when a drag, the wheel or an arrow key moves it. */
153
+ onChange?: (value: XY<number>) => void
154
+ /** Called when a drag starts, with the value where the area was pressed. */
155
+ onDragStart?: (value: XY<number>) => void
156
+ /** Called when the drag ends, with the value it ended on. */
157
+ onDragEnd?: (value: XY<number>) => void
158
+
159
+ /**
160
+ * The pad renders exactly what you compose here; there is no default
161
+ * markup to fall back to.
162
+ *
163
+ * @example
164
+ * <XYPad.Root value={[x, y]} min={0} max={100} onChange={setValue}>
165
+ * <XYPad.Area>
166
+ * <XYPad.Thumb />
167
+ * </XYPad.Area>
168
+ * </XYPad.Root>
169
+ */
170
+ children: ReactNode
84
171
  }
85
172
 
86
173
  export interface XYPadMethods {
@@ -92,11 +179,24 @@ export interface XYPadMethods {
92
179
  type Props = XYPadProps &
93
180
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadProps>
94
181
 
95
- export const Root = forwardRef<XYPadMethods, Props>(
182
+ /**
183
+ * The wheel only acts while the focus is inside, so that scrolling a page past
184
+ * the control does not change its value.
185
+ */
186
+ const WHEEL_OPTIONS = { requireFocus: true }
187
+
188
+ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
96
189
  (
97
190
  {
98
- x: _x,
99
- y: _y,
191
+ value,
192
+ min: _min,
193
+ max: _max,
194
+ step: _step = 1,
195
+ scale: _scale = linearScale,
196
+ reverse: _reverse = false,
197
+ wheel = DEFAULT_WHEEL_OPTIONS,
198
+ keyboard = DEFAULT_KEYBOARD_OPTIONS,
199
+ dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
100
200
  className,
101
201
  style,
102
202
  externalStyles: _externalStyles,
@@ -114,238 +214,224 @@ export const Root = forwardRef<XYPadMethods, Props>(
114
214
  }: Props,
115
215
  forwardedRef,
116
216
  ) => {
117
- const x = useMemo(() => {
118
- return { ...defaultValueOptions, ..._x }
119
- }, [_x])
120
- const y = useMemo(() => {
121
- return { ...defaultValueOptions, ..._y }
122
- }, [_y])
123
-
124
217
  // -- state and ref ---
125
218
  const rootRef = useRef<HTMLDivElement>(null)
126
- const areaElementRef = useRef<HTMLDivElement>(null)
219
+ const areaRef = useRef<HTMLDivElement>(null)
127
220
  const thumbRef = useRef<XYPadThumbMethods>(null)
128
221
 
129
222
  // --- interpret props ---
130
223
  const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
131
-
132
- const nx = normalizeValue(x.value, x.min, x.max, x.skew)
133
- const ny = normalizeValue(y.value, y.min, y.max, y.skew)
134
- const percentX = toFixed((x.reverse ? 1 - nx : nx) * 100)
135
- const percentY = toFixed((y.reverse ? 1 - ny : ny) * 100)
136
-
137
- let areaProps: XYPadAreaProps = {}
138
- let thumbProps: XYPadThumbProps = {}
139
- if (children != undefined) {
140
- React.Children.forEach(children, (child) => {
141
- if (React.isValidElement(child)) {
142
- if (child.type == Thumb) {
143
- thumbProps = child.props as XYPadThumbProps
144
- } else if (child.type == Area) {
145
- areaProps = child.props as XYPadAreaProps
146
- } else {
147
- throw new Error('only <XYPadThumb> or <XYPadArea>')
148
- }
149
- } else {
150
- throw new Error('children is an invalid element.')
151
- }
152
- })
153
- }
224
+ const inactive = disabled || readonly
225
+
226
+ const min = useMemo(() => toXY(_min), [_min])
227
+ const max = useMemo(() => toXY(_max), [_max])
228
+ const step = useMemo(() => toXY(_step), [_step])
229
+ const scale = useMemo(() => toXY(_scale), [_scale])
230
+ const reverse = useMemo(() => toXY(_reverse), [_reverse])
231
+
232
+ const percent = useMemo((): XY<number> => {
233
+ const normalized = [0, 1].map((i) =>
234
+ scale[i].normalize(value[i], min[i], max[i]),
235
+ )
236
+ return [0, 1].map((i) =>
237
+ toFixed((reverse[i] ? 1 - normalized[i] : normalized[i]) * 100),
238
+ ) as XY<number>
239
+ }, [value, min, max, scale, reverse])
154
240
 
155
241
  // --- internal functions ---
156
- const onDrag = useCallback(
157
- (nx: number, ny: number) => {
158
- if (!onChange || readonly) return
159
- const vx = rawValue(x.reverse ? 1 - nx : nx, x.min, x.max, x.skew)
160
- const vy = rawValue(y.reverse ? 1 - ny : ny, y.min, y.max, y.skew)
161
- const newX = clamp(stepValue(vx, x.step), x.min, x.max)
162
- const newY = clamp(stepValue(vy, y.step), y.min, y.max)
163
- onChange(newX, newY)
164
- },
165
- [
166
- onChange,
167
- readonly,
168
- x.max,
169
- x.min,
170
- x.reverse,
171
- x.skew,
172
- x.step,
173
- y.max,
174
- y.min,
175
- y.reverse,
176
- y.skew,
177
- y.step,
178
- ],
242
+ // `AxisOptions` extends `ValueRange`, so the same pair also describes the
243
+ // scaling for `applyDelta`. Its `reverse` only concerns the drag, where
244
+ // positions follow the screen; the key and wheel handlers below flip the
245
+ // direction themselves.
246
+ const axis = useMemo(
247
+ (): XY<AxisOptions> =>
248
+ [0, 1].map((i) => ({
249
+ min: min[i],
250
+ max: max[i],
251
+ step: step[i],
252
+ scale: scale[i],
253
+ reverse: reverse[i],
254
+ })) as XY<AxisOptions>,
255
+ [min, max, step, scale, reverse],
179
256
  )
180
257
 
181
- const updateValueByEvent = useCallback(
258
+ useCheckSteps({
259
+ component: 'XYPad',
260
+ axis: 'x',
261
+ range: axis[0],
262
+ keyboard,
263
+ wheel,
264
+ })
265
+ useCheckSteps({
266
+ component: 'XYPad',
267
+ axis: 'y',
268
+ range: axis[1],
269
+ keyboard,
270
+ wheel,
271
+ })
272
+
273
+ const withAxis = useCallback(
274
+ (axis: 0 | 1, next: number): XY<number> =>
275
+ axis === 0 ? [next, value[1]] : [value[0], next],
276
+ [value],
277
+ )
278
+
279
+ /** @param i 0 = x, 1 = y */
280
+ const nudge = useCallback(
182
281
  (
183
- eventType: InputEventOption[0],
184
- target: Required<ValueOptions>,
185
- x: number,
186
- ) => {
187
- let v
188
- if (eventType == 'normalized') {
189
- const n = normalizeValue(
190
- target.value,
191
- target.min,
192
- target.max,
193
- target.skew,
194
- )
195
- v = rawValue(n + x, target.min, target.max, target.skew)
196
- } else {
197
- v = target.value + x
198
- }
199
- return clamp(stepValue(v, target.step), target.min, target.max)
200
- },
201
- [],
282
+ i: 0 | 1,
283
+ direction: number,
284
+ option: ModifierValue<InputEventOption>,
285
+ modifiers: ModifierState,
286
+ ): XY<number> =>
287
+ withAxis(
288
+ i,
289
+ applyDelta(value[i], direction, option, axis[i], modifiers),
290
+ ),
291
+ [value, axis, withAxis],
202
292
  )
203
293
 
204
294
  const handleKeyDown = useCallback(
205
295
  (event: React.KeyboardEvent<HTMLDivElement>) => {
206
- if (!onChange || readonly) return
207
296
  const key = event.key
208
- if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
209
- const isHorizontal = key == 'ArrowRight' || key == 'ArrowLeft'
210
- const target = isHorizontal ? x : y
211
- if (!target.keyboard) return
212
- event.preventDefault()
213
- let delta = target.keyboard[1]
214
- if (key == 'ArrowLeft' || key == 'ArrowUp') delta *= -1
215
- if (target.reverse) delta *= -1
216
- const newValue = updateValueByEvent(target.keyboard[0], target, delta)
217
- if (isHorizontal) {
218
- onChange(newValue, y.value)
219
- } else {
220
- onChange(x.value, newValue)
221
- }
222
- }
297
+ if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
298
+ return
299
+
300
+ // The key picks the axis, whichever of the two inputs holds the
301
+ // focus: the pad is one control to the person moving it, and the focus
302
+ // lands on the x input, so reading the axis off the input would leave
303
+ // the y axis with no keys at all.
304
+ const i: 0 | 1 = key === 'ArrowRight' || key === 'ArrowLeft' ? 0 : 1
305
+ event.preventDefault()
306
+ if (!onChange || inactive || !keyboard) return
307
+ let direction = 1
308
+ if (key === 'ArrowLeft' || key === 'ArrowUp') direction *= -1
309
+ if (reverse[i]) direction *= -1
310
+ onChange(nudge(i, direction, keyboard, event))
223
311
  },
224
- [onChange, readonly, x, y, updateValueByEvent],
312
+ [onChange, inactive, keyboard, reverse, nudge],
225
313
  )
226
314
 
227
315
  // --- hooks ---
228
- const { refCallback: dragRefCallback } = useDragWithElement<HTMLDivElement>(
229
- {
230
- baseElementRef: areaElementRef,
231
- cursor: readonly ? undefined : externalStyles.cursor,
232
- updateOnPointerDown: true,
233
- onDrag: onDrag,
234
- onDragStart: (nx, ny) => {
235
- if (readonly) return
236
-
237
- if (externalStyles.userSelectNone) addUserSelectNone()
238
-
239
- thumbRef.current?.focus()
240
-
241
- const valueX = clamp(
242
- stepValue(rawValue(nx, x.min, x.max, x.skew), x.step),
243
- x.min,
244
- x.max,
245
- )
246
- const valueY = clamp(
247
- stepValue(rawValue(ny, y.min, y.max, y.skew), y.step),
248
- y.min,
249
- y.max,
250
- )
251
-
252
- onDragStart?.(valueX, valueY)
253
- },
254
- onDragEnd: (nx, ny) => {
255
- if (readonly) return
256
-
257
- if (externalStyles.userSelectNone) removeUserSelectNone()
258
-
259
- const valueX = clamp(
260
- stepValue(rawValue(nx, x.min, x.max, x.skew), x.step),
261
- x.min,
262
- x.max,
263
- )
264
- const valueY = clamp(
265
- stepValue(rawValue(ny, y.min, y.max, y.skew), y.step),
266
- y.min,
267
- y.max,
268
- )
269
- onDragEnd?.(valueX, valueY)
270
- },
316
+
317
+ const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
318
+ axis,
319
+ baseElementRef: areaRef,
320
+ sensitivity: (state) =>
321
+ selectModifier(dragSensitivity, state.event).value,
322
+ updateOnPointerDown: true,
323
+ cursor: inactive ? undefined : externalStyles.cursor,
324
+ shouldStart: () => !inactive,
325
+ onChange: (v) => {
326
+ if (inactive) return
327
+ onChange?.(v)
271
328
  },
272
- )
329
+ onDragStart: (v) => {
330
+ if (inactive) return
331
+ thumbRef.current?.focus()
332
+ onDragStart?.(v)
333
+ },
334
+ onDragEnd: (v) => {
335
+ if (inactive) return
336
+ onDragEnd?.(v)
337
+ },
338
+ })
273
339
 
274
340
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
275
- if (!onChange || readonly) return
276
- const target = event.shiftKey ? x : y
277
- if (!target.wheel) return
341
+ if (!onChange || inactive || !wheel) return
342
+ // Browsers turn shift+wheel into horizontal scrolling: `deltaY` comes
343
+ // out empty and `deltaX` carries the movement. Reading whichever axis
344
+ // moved keeps shift working as the x-axis modifier — and picks up a
345
+ // trackpad's own horizontal gesture, which never had a modifier.
346
+ const horizontal = event.deltaX !== 0
347
+ const delta = horizontal ? event.deltaX : event.deltaY
348
+ if (delta === 0) return
349
+ const i: 0 | 1 = horizontal || event.shiftKey ? 0 : 1
278
350
  event.preventDefault()
279
- let delta = target.wheel[1]
280
- if (event.deltaY < 0) delta *= -1
281
- if (target.reverse) delta *= -1
282
- const newValue = updateValueByEvent(target.wheel[0], target, delta)
283
- if (event.shiftKey) {
284
- onChange(newValue, y.value)
285
- } else {
286
- onChange(x.value, newValue)
287
- }
288
- })
351
+ let direction = 1
352
+ if (delta < 0) direction *= -1
353
+ if (reverse[i]) direction *= -1
354
+ onChange(nudge(i, direction, wheel, event))
355
+ }, WHEEL_OPTIONS)
356
+
357
+ // Composed once, so React attaches the refs a single time instead of
358
+ // detaching and re-attaching on every render.
359
+ const rootRefCallback = useComposedRefs<HTMLDivElement>(
360
+ rootRef,
361
+ dragRefCallback,
362
+ wheelRefCallback,
363
+ )
364
+
365
+ const context = useMemo(
366
+ () => ({
367
+ value,
368
+ min,
369
+ max,
370
+ step,
371
+ scale,
372
+ reverse,
373
+ disabled,
374
+ readonly,
375
+ onChange,
376
+ percent,
377
+ areaRef,
378
+ thumbRef,
379
+ }),
380
+ [
381
+ value,
382
+ min,
383
+ max,
384
+ step,
385
+ scale,
386
+ reverse,
387
+ disabled,
388
+ readonly,
389
+ onChange,
390
+ percent,
391
+ ],
392
+ )
289
393
 
290
394
  useImperativeHandle(forwardedRef, () => {
291
395
  return {
292
396
  focus() {
293
- thumbRef.current?.focus()
397
+ if (!disabled) thumbRef.current?.focus()
294
398
  },
295
399
  blur() {
296
400
  thumbRef.current?.blur()
297
401
  },
298
402
  original: rootRef,
299
403
  }
300
- }, [])
404
+ }, [disabled])
301
405
 
302
406
  return (
303
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions
304
- <div
305
- className={clsx('tremolo-xy-pad', className)}
306
- ref={composeRefs(rootRef, wheelRefCallback, dragRefCallback)}
307
- tabIndex={-1}
308
- aria-disabled={disabled}
309
- aria-readonly={readonly}
310
- style={{
311
- margin: `calc(${styleHelper(thumbProps?.size ?? 22)} / 2)`, // half thumb size
312
- cursor: readonly ? 'not-allowed' : 'pointer',
313
- WebkitTapHighlightColor: 'transparent',
314
- ...style,
315
- }}
316
- onPointerDown={onPointerDown}
317
- onKeyDown={(event) => {
318
- handleKeyDown(event)
319
- onKeyDown?.(event)
320
- }}
321
- onFocus={(event) => {
322
- thumbRef.current?.focus()
323
- onFocus?.(event)
324
- }}
325
- onBlur={(event) => {
326
- thumbRef.current?.blur()
327
- onBlur?.(event)
328
- }}
329
- {...props}
330
- >
331
- <div className="tremolo-xy-pad-area-wrapper" ref={areaElementRef}>
332
- <Area
333
- __thumb={
334
- <Thumb
335
- ref={thumbRef}
336
- __disabled={disabled}
337
- __readonly={readonly}
338
- __css={{
339
- top: `${percentY}%`,
340
- left: `${percentX}%`,
341
- }}
342
- {...thumbProps}
343
- />
344
- }
345
- {...areaProps}
346
- />
407
+ <XYPadProvider value={context}>
408
+ {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the group handles pointer and keyboard input shared by its two range controls */}
409
+ <div
410
+ className={className}
411
+ ref={rootRefCallback}
412
+ role="group"
413
+ tabIndex={-1}
414
+ data-disabled={disabled ? '' : undefined}
415
+ data-readonly={readonly ? '' : undefined}
416
+ style={style}
417
+ onPointerDown={onPointerDown}
418
+ onKeyDown={(event) => {
419
+ handleKeyDown(event)
420
+ onKeyDown?.(event)
421
+ }}
422
+ onFocus={(event) => {
423
+ if (!disabled) thumbRef.current?.focus()
424
+ onFocus?.(event)
425
+ }}
426
+ onBlur={(event) => {
427
+ thumbRef.current?.blur()
428
+ onBlur?.(event)
429
+ }}
430
+ {...props}
431
+ >
432
+ {children}
347
433
  </div>
348
- </div>
434
+ </XYPadProvider>
349
435
  )
350
436
  },
351
437
  )
@@ -361,3 +447,9 @@ export const XYPad = {
361
447
 
362
448
  export { type XYPadThumbProps, type XYPadThumbMethods } from './Thumb'
363
449
  export { type XYPadAreaProps } from './Area'
450
+ export {
451
+ useXYPadContext,
452
+ type XYPadContextValue,
453
+ type XY,
454
+ type XYInput,
455
+ } from './context'