@tremolo-ui/react 0.4.0 → 0.5.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 (62) hide show
  1. package/dist/index.cjs +1125 -1437
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +561 -243
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +561 -243
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1125 -1438
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -10
  11. package/src/components/AnimationCanvas/index.tsx +84 -155
  12. package/src/components/Knob/ActiveLine.tsx +6 -6
  13. package/src/components/Knob/InactiveLine.tsx +9 -11
  14. package/src/components/Knob/SVGRoot.tsx +9 -18
  15. package/src/components/Knob/context.tsx +57 -89
  16. package/src/components/Knob/index.css +6 -2
  17. package/src/components/Knob/index.tsx +90 -61
  18. package/src/components/NumberInput/DecrementStepper.tsx +17 -50
  19. package/src/components/NumberInput/IncrementStepper.tsx +17 -50
  20. package/src/components/NumberInput/InputField.tsx +123 -0
  21. package/src/components/NumberInput/Stepper.tsx +90 -12
  22. package/src/components/NumberInput/context.tsx +74 -146
  23. package/src/components/NumberInput/index.css +38 -102
  24. package/src/components/NumberInput/index.tsx +252 -98
  25. package/src/components/NumberInput/stepperButton.tsx +90 -0
  26. package/src/components/Piano/index.tsx +272 -288
  27. package/src/components/Piano/keyboardShortcuts.ts +34 -4
  28. package/src/components/PointsEditor/Background.tsx +8 -2
  29. package/src/components/PointsEditor/Container.tsx +19 -9
  30. package/src/components/PointsEditor/Point.tsx +104 -24
  31. package/src/components/PointsEditor/context.tsx +28 -74
  32. package/src/components/PointsEditor/index.css +5 -0
  33. package/src/components/PointsEditor/index.tsx +77 -43
  34. package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
  35. package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
  36. package/src/components/Slider/Thumb.tsx +55 -68
  37. package/src/components/Slider/Track.tsx +15 -15
  38. package/src/components/Slider/context.tsx +36 -51
  39. package/src/components/Slider/index.css +18 -5
  40. package/src/components/Slider/index.tsx +127 -146
  41. package/src/components/Slider/type.ts +4 -4
  42. package/src/components/XYPad/Area.tsx +16 -7
  43. package/src/components/XYPad/Thumb.tsx +62 -74
  44. package/src/components/XYPad/context.tsx +46 -0
  45. package/src/components/XYPad/index.css +12 -0
  46. package/src/components/XYPad/index.tsx +190 -230
  47. package/src/hooks/useDrag.ts +1 -1
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +29 -9
  51. package/src/index.ts +15 -9
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/NumberInput/InternalInput.tsx +0 -216
  54. package/src/components/NumberInput/type.ts +0 -58
  55. package/src/components/Piano/KeyLabel.tsx +0 -51
  56. package/src/components/Piano/context.tsx +0 -73
  57. package/src/components/Piano/key.tsx +0 -205
  58. package/src/components/_util/type.ts +0 -1
  59. package/src/hooks/useDragWithElement.ts +0 -100
  60. package/src/hooks/usePianoDrag.ts +0 -75
  61. package/src/hooks/useRefCallbackEvent.ts +0 -34
  62. /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
@@ -3,33 +3,33 @@ import React, {
3
3
  ComponentPropsWithoutRef,
4
4
  CSSProperties,
5
5
  forwardRef,
6
- ReactElement,
6
+ ReactNode,
7
7
  useCallback,
8
8
  useImperativeHandle,
9
9
  useMemo,
10
10
  useRef,
11
11
  } from 'react'
12
12
 
13
+ import type { AxisOptions, XY } 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,
21
19
  xor,
20
+ type Scale,
22
21
  } from '@tremolo-ui/functions'
23
22
 
24
- import { useDragWithElement } from '../../hooks/useDragWithElement'
23
+ import { useDragValue } from '../../hooks/useDragValue'
25
24
  import { useWheel } from '../../hooks/useWheel'
26
25
  import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
26
+ import { useComposedRefs } from '../_util/composeRefs'
27
27
 
28
28
  import { SliderProvider } from './context'
29
- import { Scale } from './Scale'
30
- import { ScaleOption } from './ScaleOption'
31
- import { Thumb, SliderThumbMethods, SliderThumbProps } from './Thumb'
32
- import { Track, SliderTrackProps } from './Track'
29
+ import { Marks } from './Marks'
30
+ import { MarksOption } from './MarksOption'
31
+ import { Thumb, SliderThumbMethods } from './Thumb'
32
+ import { Track } from './Track'
33
33
 
34
34
  const defaultExternalStyles: SliderProps['externalStyles'] = {
35
35
  userSelectNone: true,
@@ -44,7 +44,16 @@ export interface SliderProps {
44
44
 
45
45
  // optional
46
46
  step?: number
47
- skew?: number // TODO | SkewFunction
47
+ /**
48
+ * How the value is distributed across the travel.
49
+ *
50
+ * Pick one of the scales from `@tremolo-ui/functions`: `linearScale`,
51
+ * `exponentialScale`, `curveScale(n)`, `symmetricSkewScale(n)`, or
52
+ * `skewScale(n)` for a value that has to match a JUCE parameter.
53
+ *
54
+ * @default linearScale
55
+ */
56
+ scale?: Scale
48
57
  /**
49
58
  * slider orientation
50
59
  * aria-orientation property is also applied.
@@ -84,9 +93,18 @@ export interface SliderProps {
84
93
  onChange?: (value: number) => void
85
94
  onDragStart?: (value: number) => void
86
95
  onDragEnd?: (value: number) => void
87
- /** \<SliderThumb /> | \<SliderTrack /> */
88
- children?: ReactElement | ReactElement[]
89
- // ReactElement<typeof SliderThumb, typeof SliderTrack>[]
96
+ /**
97
+ * The slider renders exactly what you compose here; there is no default
98
+ * markup to fall back to.
99
+ *
100
+ * @example
101
+ * <Slider.Root value={value} min={0} max={100} onChange={setValue}>
102
+ * <Slider.Track>
103
+ * <Slider.Thumb />
104
+ * </Slider.Track>
105
+ * </Slider.Root>
106
+ */
107
+ children: ReactNode
90
108
  }
91
109
 
92
110
  export interface SliderMethods {
@@ -97,14 +115,20 @@ export interface SliderMethods {
97
115
  type Props = SliderProps &
98
116
  Omit<ComponentPropsWithoutRef<'div'>, keyof SliderProps>
99
117
 
100
- const Root = forwardRef<SliderMethods, Props>(
118
+ /**
119
+ * The wheel only acts while the focus is inside, so that scrolling a page past
120
+ * the control does not change its value.
121
+ */
122
+ const WHEEL_OPTIONS = { requireFocus: true }
123
+
124
+ export const Root = forwardRef<SliderMethods, Props>(
101
125
  (
102
126
  {
103
127
  value,
104
128
  min,
105
129
  max,
106
130
  step = 1,
107
- skew = 1,
131
+ scale = linearScale,
108
132
  vertical = false,
109
133
  reverse = false,
110
134
  externalStyles: _externalStyles,
@@ -127,13 +151,13 @@ const Root = forwardRef<SliderMethods, Props>(
127
151
  forwardedRef,
128
152
  ) => {
129
153
  // -- state and ref ---
130
- const trackElementRef = useRef<HTMLDivElement>(null)
154
+ const trackRef = useRef<HTMLDivElement>(null)
131
155
  const thumbRef = useRef<SliderThumbMethods>(null)
132
156
 
133
157
  // --- interpret props ---
134
158
  const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
135
159
 
136
- const p = toFixed(normalizeValue(value, min, max, skew) * 100)
160
+ const p = toFixed(scale.normalize(value, min, max) * 100)
137
161
  const rev = toFixed(100 - p)
138
162
  // NOTE
139
163
  // normal -> normal (right)
@@ -146,51 +170,13 @@ const Root = forwardRef<SliderMethods, Props>(
146
170
  )
147
171
  const percent = displayReversed ? rev : p
148
172
 
149
- let trackProps: SliderTrackProps = {}
150
- let thumbProps: SliderThumbProps = {}
151
- let scaleComponent: ReactElement | undefined = undefined
152
- if (children != undefined) {
153
- React.Children.forEach(children, (child) => {
154
- if (React.isValidElement(child)) {
155
- if (child.type == Thumb) {
156
- thumbProps = child.props as SliderThumbProps
157
- } else if (child.type == Track) {
158
- trackProps = child.props as SliderTrackProps
159
- } else if (child.type == Scale) {
160
- scaleComponent = child
161
- } else {
162
- throw new Error('only <SliderThumb> or <SliderTrack>')
163
- }
164
- } else {
165
- throw new Error('children is an invalid element.')
166
- }
167
- })
168
- }
169
-
170
173
  // --- internal functions ---
171
- const onDrag = useCallback(
172
- (nx: number, ny: number) => {
173
- if (!onChange || readonly) return
174
- const n = vertical ? ny : nx
175
- const v = rawValue(displayReversed ? 1 - n : n, min, max, skew)
176
- const v2 = clamp(stepValue(v, step), min, max)
177
- onChange(v2)
178
- },
179
- [displayReversed, max, min, onChange, readonly, skew, step, vertical],
180
- )
181
-
182
- const updateValueByEvent = useCallback(
183
- (eventType: InputEventOption[0], x: number) => {
184
- let newValue
185
- if (eventType == 'normalized') {
186
- const n = normalizeValue(value, min, max, skew)
187
- newValue = rawValue(n + x, min, max, skew)
188
- } else {
189
- newValue = value + x
190
- }
191
- return clamp(stepValue(newValue, step), min, max)
192
- },
193
- [max, min, skew, step, value],
174
+ // The pointer is normalized against the track on both axes; only the one
175
+ // the slider runs along is read back. `AxisOptions` extends `ValueRange`,
176
+ // so the same object also describes the scaling for `applyDelta`.
177
+ const axis: AxisOptions = useMemo(
178
+ () => ({ min, max, step, scale, reverse: displayReversed }),
179
+ [min, max, step, scale, displayReversed],
194
180
  )
195
181
 
196
182
  const handleKeyDown = useCallback(
@@ -199,64 +185,91 @@ const Root = forwardRef<SliderMethods, Props>(
199
185
  const key = event.key
200
186
  if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
201
187
  event.preventDefault()
202
- let x =
203
- key == 'ArrowRight' || key == 'ArrowUp' ? keyboard[1] : -keyboard[1]
204
- if (reverse) x *= -1
205
- onChange(updateValueByEvent(keyboard[0], x))
188
+ let direction = key == 'ArrowRight' || key == 'ArrowUp' ? 1 : -1
189
+ if (reverse) direction *= -1
190
+ onChange(applyDelta(value, direction, keyboard, axis))
206
191
  }
207
192
  },
208
- [keyboard, onChange, readonly, reverse, updateValueByEvent],
193
+ [keyboard, onChange, readonly, reverse, value, axis],
209
194
  )
210
195
 
211
196
  // --- hooks ---
212
- const { refCallback: dragRefCallback } = useDragWithElement<HTMLDivElement>(
213
- {
214
- baseElementRef: trackElementRef,
215
- cursor: readonly ? undefined : externalStyles.cursor,
216
- updateOnPointerDown: true,
217
- onDrag: onDrag,
218
- onDragStart: (nx, ny) => {
219
- if (readonly) return
220
- if (externalStyles.userSelectNone) addUserSelectNone()
197
+ const valueOf = (v: XY<number>) => v[vertical ? 1 : 0]
221
198
 
222
- thumbRef.current?.focus()
223
- onDragStart?.(
224
- clamp(
225
- stepValue(rawValue(vertical ? ny : nx, min, max, skew), step),
226
- min,
227
- max,
228
- ),
229
- )
230
- },
231
- onDragEnd: (nx, ny) => {
232
- if (readonly) return
199
+ const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
200
+ axis,
201
+ baseElementRef: trackRef,
202
+ cursor: readonly ? undefined : externalStyles.cursor,
203
+ updateOnPointerDown: true,
204
+ onChange: (v) => {
205
+ if (readonly) return
206
+ onChange?.(valueOf(v))
207
+ },
208
+ onDragStart: (v) => {
209
+ if (readonly) return
210
+ if (externalStyles.userSelectNone) addUserSelectNone()
233
211
 
234
- if (externalStyles.userSelectNone) removeUserSelectNone()
212
+ thumbRef.current?.focus()
213
+ onDragStart?.(valueOf(v))
214
+ },
215
+ onDragEnd: (v) => {
216
+ if (readonly) return
235
217
 
236
- onDragEnd?.(
237
- clamp(
238
- stepValue(rawValue(vertical ? ny : nx, min, max, skew), step),
239
- min,
240
- max,
241
- ),
242
- )
243
- },
218
+ if (externalStyles.userSelectNone) removeUserSelectNone()
219
+
220
+ onDragEnd?.(valueOf(v))
244
221
  },
245
- )
222
+ })
246
223
 
247
224
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
248
225
  if (!wheel || !onChange || readonly) return
249
226
  event.preventDefault()
250
- let x
227
+ let direction
251
228
  if (!vertical && event.deltaX != 0) {
252
- x = event.deltaX > 0 ? wheel[1] : -wheel[1]
229
+ direction = event.deltaX > 0 ? 1 : -1
253
230
  } else {
254
231
  if (event.deltaY == 0) return
255
- x = event.deltaY > 0 ? -wheel[1] : wheel[1]
232
+ direction = event.deltaY > 0 ? -1 : 1
256
233
  }
257
- if (vertical && reverse) x *= -1
258
- onChange(updateValueByEvent(wheel[0], x))
259
- })
234
+ if (vertical && reverse) direction *= -1
235
+ onChange(applyDelta(value, direction, wheel, axis))
236
+ }, WHEEL_OPTIONS)
237
+
238
+ // Composed once, so React attaches the refs a single time instead of
239
+ // detaching and re-attaching on every render.
240
+ const rootRefCallback = useComposedRefs<HTMLDivElement>(
241
+ dragRefCallback,
242
+ wheelRefCallback,
243
+ )
244
+
245
+ const context = useMemo(
246
+ () => ({
247
+ value,
248
+ min,
249
+ max,
250
+ step,
251
+ scale,
252
+ vertical,
253
+ reverse,
254
+ disabled,
255
+ readonly,
256
+ percent,
257
+ trackRef,
258
+ thumbRef,
259
+ }),
260
+ [
261
+ value,
262
+ min,
263
+ max,
264
+ step,
265
+ scale,
266
+ vertical,
267
+ reverse,
268
+ disabled,
269
+ readonly,
270
+ percent,
271
+ ],
272
+ )
260
273
 
261
274
  useImperativeHandle(forwardedRef, () => {
262
275
  return {
@@ -270,22 +283,10 @@ const Root = forwardRef<SliderMethods, Props>(
270
283
  }, [])
271
284
 
272
285
  return (
273
- <SliderProvider
274
- min={min}
275
- max={max}
276
- step={step}
277
- skew={skew}
278
- vertical={vertical}
279
- reverse={reverse}
280
- disabled={disabled}
281
- readonly={readonly}
282
- >
286
+ <SliderProvider value={context}>
283
287
  <div
284
288
  className={clsx('tremolo-slider', className)}
285
- ref={(div) => {
286
- wheelRefCallback(div)
287
- dragRefCallback(div)
288
- }}
289
+ ref={rootRefCallback}
289
290
  tabIndex={-1}
290
291
  role="slider"
291
292
  aria-valuenow={value}
@@ -294,10 +295,8 @@ const Root = forwardRef<SliderMethods, Props>(
294
295
  aria-orientation={vertical ? 'vertical' : 'horizontal'}
295
296
  aria-disabled={disabled}
296
297
  aria-readonly={readonly}
297
- style={{
298
- margin: `calc(${styleHelper(thumbProps?.size ?? 22)} / 2)`, // half thumb size
299
- ...style,
300
- }}
298
+ data-vertical={vertical}
299
+ style={style}
301
300
  onPointerDown={onPointerDown}
302
301
  onKeyDown={(event) => {
303
302
  handleKeyDown(event)
@@ -313,25 +312,7 @@ const Root = forwardRef<SliderMethods, Props>(
313
312
  }}
314
313
  {...props}
315
314
  >
316
- <div
317
- style={{
318
- display: 'flex',
319
- flexDirection: vertical ? 'row' : 'column',
320
- width: !vertical ? '100%' : undefined,
321
- height: vertical ? '100%' : undefined,
322
- }}
323
- >
324
- <div className="tremolo-slider-track-wrapper" ref={trackElementRef}>
325
- <Track
326
- __percent={percent}
327
- __thumb={
328
- <Thumb ref={thumbRef} __percent={percent} {...thumbProps} />
329
- }
330
- {...trackProps}
331
- />
332
- </div>
333
- {scaleComponent}
334
- </div>
315
+ {children}
335
316
  </div>
336
317
  </SliderProvider>
337
318
  )
@@ -345,13 +326,13 @@ export const Slider = {
345
326
  Root,
346
327
  Thumb,
347
328
  Track,
348
- Scale,
349
- ScaleOption,
329
+ Marks,
330
+ MarksOption,
350
331
  }
351
332
 
352
333
  export { useSliderContext } from './context'
353
334
  export { type SliderThumbMethods, type SliderThumbProps } from './Thumb'
354
335
  export { type SliderTrackProps } from './Track'
355
- export { type ScaleProps } from './Scale'
356
- export { type ScaleOptionProps } from './ScaleOption'
357
- export { type ScaleOptions, type ScaleType } from './type'
336
+ export { type MarksProps } from './Marks'
337
+ export { type MarksOptionProps } from './MarksOption'
338
+ export { type MarksOptions, type MarksType } from './type'
@@ -1,17 +1,17 @@
1
1
  import { decimalPart, toFixed } from '@tremolo-ui/functions'
2
2
 
3
- export type ScaleType = 'mark' | 'mark-number' | 'number'
4
- export type ScaleOptions = ['step', ScaleType] | [number, ScaleType]
3
+ export type MarksType = 'mark' | 'mark-number' | 'number'
4
+ export type MarksOptions = ['step', MarksType] | [number, MarksType]
5
5
 
6
6
  export function generateOptionsList(
7
- options: ScaleOptions,
7
+ options: MarksOptions,
8
8
  min: number,
9
9
  max: number,
10
10
  step: number,
11
11
  ) {
12
12
  const optionsList: {
13
13
  value: number
14
- type: ScaleType
14
+ type: MarksType
15
15
  }[] = []
16
16
  const per = options[0] == 'step' ? step : options[0]
17
17
  const count = Math.floor(max / per) - Math.ceil(min / per) + 1
@@ -1,5 +1,9 @@
1
1
  import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, CSSProperties, ReactElement } from 'react'
2
+ import { ComponentPropsWithoutRef, CSSProperties, ReactNode, Ref } from 'react'
3
+
4
+ import { useComposedRefs } from '../_util/composeRefs'
5
+
6
+ import { useXYPadContext } from './context'
3
7
 
4
8
  export interface XYPadAreaProps {
5
9
  width?: number | string
@@ -7,10 +11,9 @@ export interface XYPadAreaProps {
7
11
  color?: string
8
12
  className?: string
9
13
  style?: CSSProperties
10
- children?: ReactElement
11
-
12
- /** inherit */
13
- __thumb?: ReactElement
14
+ /** `<XYPad.Thumb />` goes here. */
15
+ children?: ReactNode
16
+ ref?: Ref<HTMLDivElement>
14
17
  }
15
18
 
16
19
  export function Area({
@@ -20,12 +23,19 @@ export function Area({
20
23
  children,
21
24
  className,
22
25
  style,
23
- __thumb,
26
+ ref,
24
27
  ...props
25
28
  }: XYPadAreaProps &
26
29
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadAreaProps>) {
30
+ const { areaRef } = useXYPadContext()
31
+
32
+ // The area is what the pointer position is normalized against, so the
33
+ // context ref is composed with any ref the caller passed.
34
+ const composedRef = useComposedRefs<HTMLDivElement>(ref, areaRef)
35
+
27
36
  return (
28
37
  <div
38
+ ref={composedRef}
29
39
  className={clsx('tremolo-xy-pad-area', className)}
30
40
  style={{
31
41
  ...{ '--color': color },
@@ -36,7 +46,6 @@ export function Area({
36
46
  {...props}
37
47
  >
38
48
  {children}
39
- {__thumb}
40
49
  </div>
41
50
  )
42
51
  }
@@ -2,30 +2,26 @@ import clsx from 'clsx'
2
2
  import {
3
3
  ComponentPropsWithoutRef,
4
4
  CSSProperties,
5
- forwardRef,
6
5
  ReactNode,
6
+ Ref,
7
7
  useImperativeHandle,
8
8
  useRef,
9
9
  } from 'react'
10
10
 
11
- export interface XYPadThumbProps {
12
- size?: number | string
13
- width?: number | string
14
- height?: number | string
11
+ import { useXYPadContext } from './context'
15
12
 
13
+ export interface XYPadThumbProps {
14
+ /**
15
+ * Size comes from the `--thumb-size` CSS variable on `XYPad.Root`, so that
16
+ * the root can reserve the matching amount of space around the area.
17
+ */
16
18
  color?: string
17
19
 
18
20
  wrapperClassName?: string
19
21
  wrapperStyle?: CSSProperties
20
22
 
21
23
  children?: ReactNode
22
-
23
- /** @internal */
24
- __disabled?: boolean
25
- /** @internal */
26
- __readonly?: boolean
27
- /** @internal */
28
- __css?: CSSProperties
24
+ ref?: Ref<XYPadThumbMethods>
29
25
  }
30
26
 
31
27
  export interface XYPadThumbMethods {
@@ -36,67 +32,59 @@ export interface XYPadThumbMethods {
36
32
  type Props = XYPadThumbProps &
37
33
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadThumbProps>
38
34
 
39
- export const Thumb = forwardRef<XYPadThumbMethods, Props>(
40
- (
41
- {
42
- size,
43
- width = 22,
44
- height = 22,
45
- color,
46
- children,
47
- wrapperClassName,
48
- wrapperStyle,
49
- className,
50
- style,
51
- __disabled,
52
- __readonly,
53
- __css,
54
- ...props
55
- }: Props,
56
- ref,
57
- ) => {
58
- const wrapperRef = useRef<HTMLDivElement>(null)
35
+ export function Thumb({
36
+ color,
37
+ children,
38
+ wrapperClassName,
39
+ wrapperStyle,
40
+ className,
41
+ style,
42
+ ref,
43
+ ...props
44
+ }: Props) {
45
+ const elementRef = useRef<HTMLDivElement>(null)
46
+ const { disabled, readonly, percent, thumbRef } = useXYPadContext()
59
47
 
60
- useImperativeHandle(ref, () => {
61
- return {
62
- focus() {
63
- wrapperRef.current?.focus()
64
- },
65
- blur() {
66
- wrapperRef.current?.blur()
67
- },
68
- }
69
- }, [])
48
+ const methods = () => ({
49
+ focus() {
50
+ elementRef.current?.focus()
51
+ },
52
+ blur() {
53
+ elementRef.current?.blur()
54
+ },
55
+ })
70
56
 
71
- return (
72
- <div
73
- className={clsx('tremolo-xy-pad-thumb-wrapper', wrapperClassName)}
74
- style={{
75
- ...__css,
76
- ...wrapperStyle,
77
- }}
78
- {...props}
79
- >
80
- {children ? (
81
- children
82
- ) : (
83
- // default thumb
84
- <div
85
- ref={wrapperRef}
86
- className={clsx('tremolo-xy-pad-thumb', className)}
87
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
88
- tabIndex={0}
89
- aria-disabled={__disabled}
90
- aria-readonly={__readonly}
91
- style={{
92
- ...{ '--color': color },
93
- width: size ?? width,
94
- height: size ?? height,
95
- ...style,
96
- }}
97
- ></div>
98
- )}
99
- </div>
100
- )
101
- },
102
- )
57
+ useImperativeHandle(ref, methods, [])
58
+ // Root focuses the thumb when a drag starts, wherever the user placed it.
59
+ useImperativeHandle(thumbRef, methods, [])
60
+
61
+ return (
62
+ <div
63
+ className={clsx('tremolo-xy-pad-thumb-wrapper', wrapperClassName)}
64
+ style={{
65
+ left: `${percent[0]}%`,
66
+ top: `${percent[1]}%`,
67
+ ...wrapperStyle,
68
+ }}
69
+ {...props}
70
+ >
71
+ {children ? (
72
+ children
73
+ ) : (
74
+ // default thumb
75
+ <div
76
+ ref={elementRef}
77
+ className={clsx('tremolo-xy-pad-thumb', className)}
78
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
79
+ tabIndex={0}
80
+ aria-disabled={disabled}
81
+ aria-readonly={readonly}
82
+ style={{
83
+ ...{ '--color': color },
84
+ ...style,
85
+ }}
86
+ ></div>
87
+ )}
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,46 @@
1
+ import { createContext, RefObject, useContext } from 'react'
2
+
3
+ import { toXY, type XY, type XYInput } from '@tremolo-ui/dom'
4
+ import type { Scale } from '@tremolo-ui/functions'
5
+
6
+ import type { XYPadThumbMethods } from './Thumb'
7
+
8
+ export { toXY, type XY, type XYInput }
9
+
10
+ export type XYPadContextValue = {
11
+ value: XY<number>
12
+ min: XY<number>
13
+ max: XY<number>
14
+ step: XY<number>
15
+ /** How the value of each axis is distributed across the travel. */
16
+ scale: XY<Scale>
17
+ reverse: XY<boolean>
18
+ disabled: boolean
19
+ readonly: boolean
20
+
21
+ /**
22
+ * Position of the thumb, 0-100 per axis, already accounting for `reverse`.
23
+ */
24
+ percent: XY<number>
25
+
26
+ /** `Area` registers its element here; `Root` normalizes the pointer against it. */
27
+ areaRef: RefObject<HTMLDivElement | null>
28
+ /** `Thumb` registers itself here; `Root` focuses it when a drag starts. */
29
+ thumbRef: RefObject<XYPadThumbMethods | null>
30
+ }
31
+
32
+ const XYPadContext = createContext<XYPadContextValue | null>(null)
33
+
34
+ export const XYPadProvider = XYPadContext.Provider
35
+
36
+ /**
37
+ * Everything here is derived during render, so there is no state to keep in
38
+ * sync: `value` comes from the props of `Root` and the rest follows from it.
39
+ */
40
+ export function useXYPadContext(): XYPadContextValue
41
+ export function useXYPadContext<T>(selector: (state: XYPadContextValue) => T): T
42
+ export function useXYPadContext<T>(selector?: (state: XYPadContextValue) => T) {
43
+ const context = useContext(XYPadContext)
44
+ if (!context) throw new Error('Missing XYPadContext.Provider in the tree')
45
+ return selector ? selector(context) : context
46
+ }