@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
@@ -1,7 +1,8 @@
1
1
  import { ComponentPropsWithoutRef, CSSProperties, forwardRef } from 'react'
2
2
 
3
+ import { cssLength } from '@tremolo-ui/dom/internal'
4
+
3
5
  import { useComposedRefs } from '../../compose-refs'
4
- import { cssLength } from '../_util/css-length'
5
6
  import { Placement } from '../_util/Placement'
6
7
 
7
8
  import { useSliderContext } from './context'
@@ -17,13 +18,15 @@ export interface SliderTrackProps {
17
18
  /** How thick the track is across that axis. Sets `--thickness`. */
18
19
  thickness?: number | string
19
20
 
20
- /** Colour of the part from `min` to the value. Sets `--active`. */
21
- active?: string
22
- /** Colour of the rest of the track. Sets `--inactive`. */
23
- inactive?: string
21
+ /** Colour of the part from `min` to the value. Sets `--active-color`. */
22
+ activeColor?: string
23
+ /** Colour of the rest of the track. Sets `--inactive-color`. */
24
+ inactiveColor?: string
24
25
 
25
26
  style?: CSSProperties &
26
- CSSVariables<'length' | 'thickness' | 'active' | 'inactive' | 'percent'>
27
+ CSSVariables<
28
+ 'length' | 'thickness' | 'active-color' | 'inactive-color' | 'percent'
29
+ >
27
30
  }
28
31
 
29
32
  type Props = SliderTrackProps &
@@ -34,8 +37,8 @@ export const Track = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
34
37
  {
35
38
  length,
36
39
  thickness,
37
- active,
38
- inactive,
40
+ activeColor,
41
+ inactiveColor,
39
42
  children,
40
43
  className,
41
44
  style,
@@ -43,7 +46,7 @@ export const Track = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
43
46
  },
44
47
  forwardedRef,
45
48
  ) {
46
- const { vertical, reverse, disabled, percent, trackRef } =
49
+ const { orientation, reverse, disabled, percent, trackRef } =
47
50
  useSliderContext()
48
51
 
49
52
  // The track is what the pointer position is normalized against, so the
@@ -55,14 +58,14 @@ export const Track = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
55
58
  ref={composedRef}
56
59
  className={className}
57
60
  data-disabled={disabled ? '' : undefined}
58
- data-orientation={vertical ? 'vertical' : 'horizontal'}
61
+ data-orientation={orientation}
59
62
  // Which end the value grows from. `percent` is already the position on
60
63
  // screen, so this only says which side of it is the filled one.
61
- data-flipped={vertical !== reverse ? '' : undefined}
64
+ data-flipped={(orientation === 'vertical') !== reverse ? '' : undefined}
62
65
  style={
63
66
  {
64
- '--active': active,
65
- '--inactive': inactive,
67
+ '--active-color': activeColor,
68
+ '--inactive-color': inactiveColor,
66
69
  '--length': cssLength(length),
67
70
  '--thickness': cssLength(thickness),
68
71
  // Where the value sits, for the theme to paint the fill with. The
@@ -11,14 +11,14 @@ export type SliderContextValue = {
11
11
  step: number
12
12
  /** How the value is distributed across the travel. */
13
13
  scale: Scale
14
- vertical: boolean
14
+ orientation: 'horizontal' | 'vertical'
15
15
  reverse: boolean
16
16
  disabled: boolean
17
- readonly: boolean
17
+ readOnly: boolean
18
18
  onChange?: (value: number) => void
19
19
  /**
20
20
  * Position of the thumb, 0-100, already accounting for the display direction
21
- * implied by `vertical` and `reverse`.
21
+ * implied by `orientation` and `reverse`.
22
22
  */
23
23
  percent: number
24
24
 
@@ -3,6 +3,7 @@ import React, {
3
3
  CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
+ Ref,
6
7
  useCallback,
7
8
  useImperativeHandle,
8
9
  useMemo,
@@ -10,24 +11,29 @@ import React, {
10
11
  } from 'react'
11
12
 
12
13
  import {
13
- applyDelta,
14
14
  type AxisOptions,
15
+ type ChangeSource,
16
+ DEFAULT_DRAG_SENSITIVITY,
17
+ DEFAULT_KEYBOARD_OPTIONS,
18
+ DEFAULT_WHEEL_OPTIONS,
15
19
  type InputEventOption,
16
20
  type ModifierValue,
17
- selectModifier,
21
+ valuePercent,
18
22
  type XY,
19
23
  } from '@tremolo-ui/dom'
20
- import { linearScale, type Scale, toFixed } from '@tremolo-ui/functions'
24
+ import {
25
+ applyDelta,
26
+ arrowKeyDirection,
27
+ selectModifier,
28
+ wheelDirection,
29
+ } from '@tremolo-ui/dom/internal'
30
+ import { linearScale, type Scale } from '@tremolo-ui/functions'
21
31
 
22
32
  import { useComposedRefs } from '../../compose-refs'
33
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
23
34
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
24
35
  import { useDragValue } from '../../hooks/useDragValue'
25
36
  import { useWheel } from '../../hooks/useWheel'
26
- import {
27
- DEFAULT_DRAG_SENSITIVITY,
28
- DEFAULT_KEYBOARD_OPTIONS,
29
- DEFAULT_WHEEL_OPTIONS,
30
- } from '../../input-event'
31
37
 
32
38
  import { SliderProvider } from './context'
33
39
  import { Marks } from './Marks'
@@ -35,10 +41,6 @@ import { MarksOption } from './MarksOption'
35
41
  import { Thumb, SliderThumbMethods } from './Thumb'
36
42
  import { Track } from './Track'
37
43
 
38
- const defaultExternalStyles: SliderProps['externalStyles'] = {
39
- cursor: 'pointer',
40
- }
41
-
42
44
  export interface SliderProps {
43
45
  /** The current value. The slider shows only this, so update it from `onChange`. */
44
46
  value: number
@@ -65,14 +67,15 @@ export interface SliderProps {
65
67
  */
66
68
  scale?: Scale
67
69
  /**
68
- * Run the slider vertically, with the value growing upwards. The range input
69
- * inside the thumb takes its `aria-orientation` from this.
70
+ * Which way the slider runs. A vertical slider grows upwards. Every part
71
+ * carries the same value as `data-orientation`, and the range input inside
72
+ * the thumb takes its `aria-orientation` from it.
70
73
  *
71
- * @default false
74
+ * @default 'horizontal'
72
75
  */
73
- vertical?: boolean
76
+ orientation?: 'horizontal' | 'vertical'
74
77
  /**
75
- * Grow the value the other way: leftwards, or downwards when `vertical`.
78
+ * Grow the value the other way: leftwards, or downwards when vertical.
76
79
  * The arrow keys follow the direction on screen.
77
80
  *
78
81
  * @default false
@@ -83,11 +86,9 @@ export interface SliderProps {
83
86
  * The cursor to show while dragging. It is set on the dragged element, so it
84
87
  * stays while the pointer is outside the slider.
85
88
  *
86
- * @default { cursor: 'pointer' }
89
+ * @default 'pointer'
87
90
  */
88
- externalStyles?: {
89
- cursor?: CSSProperties['cursor']
90
- }
91
+ dragCursor?: CSSProperties['cursor']
91
92
  /**
92
93
  * How much one notch of the wheel moves the value. It only acts while the
93
94
  * focus is inside, so that scrolling the page past the slider leaves it
@@ -144,13 +145,28 @@ export interface SliderProps {
144
145
  * Make the value unchangeable.
145
146
  * The parts carry `data-readonly` while it is set.
146
147
  */
147
- readonly?: boolean
148
+ readOnly?: boolean
148
149
  /** Called with the new value when a drag, the wheel or an arrow key moves it. */
149
150
  onChange?: (value: number) => void
150
- /** Called when a drag starts, with the value where the track was pressed. */
151
- onDragStart?: (value: number) => void
152
- /** Called when the drag ends, with the value it ended on. */
153
- onDragEnd?: (value: number) => void
151
+ /**
152
+ * Called when a change of the value starts — a press on the track, the first wheel notch
153
+ * or arrow key — with the value
154
+ * before it and what it is made with. A host recording automation can treat
155
+ * the control as touched from here until `onChangeEnd`.
156
+ */
157
+ onChangeStart?: (value: number, source: ChangeSource) => void
158
+ /**
159
+ * Called when the change ends, with the value it ended on: on release, or
160
+ * `changeEndDelay` after the last wheel notch or arrow key.
161
+ */
162
+ onChangeEnd?: (value: number, source: ChangeSource) => void
163
+ /**
164
+ * How long after the last wheel notch or arrow key the change counts as
165
+ * over, in milliseconds. Neither has an event that says it is done.
166
+ *
167
+ * @default 500
168
+ */
169
+ changeEndDelay?: number
154
170
  /**
155
171
  * The slider renders exactly what you compose here; there is no default
156
172
  * markup to fall back to.
@@ -163,6 +179,13 @@ export interface SliderProps {
163
179
  * </Slider.Root>
164
180
  */
165
181
  children: ReactNode
182
+
183
+ /**
184
+ * Receives `focus` and `blur`, which act on the range input inside the thumb —
185
+ * the element that takes the focus — and do nothing while the slider is
186
+ * disabled. `ref` reaches the root element itself.
187
+ */
188
+ actionsRef?: Ref<SliderMethods>
166
189
  }
167
190
 
168
191
  export interface SliderMethods {
@@ -179,7 +202,7 @@ type Props = SliderProps &
179
202
  */
180
203
  const WHEEL_OPTIONS = { requireFocus: true }
181
204
 
182
- export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
205
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
183
206
  (
184
207
  {
185
208
  value,
@@ -187,17 +210,18 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
187
210
  max,
188
211
  step = 1,
189
212
  scale = linearScale,
190
- vertical = false,
213
+ orientation = 'horizontal',
191
214
  reverse = false,
192
- externalStyles: _externalStyles,
215
+ dragCursor = 'pointer',
193
216
  wheel = DEFAULT_WHEEL_OPTIONS,
194
217
  keyboard = DEFAULT_KEYBOARD_OPTIONS,
195
218
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
196
219
  disabled = false,
197
- readonly = false,
220
+ readOnly = false,
198
221
  onChange,
199
- onDragStart,
200
- onDragEnd,
222
+ onChangeStart,
223
+ onChangeEnd,
224
+ changeEndDelay,
201
225
  className,
202
226
  style,
203
227
  children,
@@ -205,6 +229,7 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
205
229
  onBlur,
206
230
  onPointerDown,
207
231
  onKeyDown,
232
+ actionsRef,
208
233
  ...props
209
234
  }: Props,
210
235
  forwardedRef,
@@ -213,18 +238,16 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
213
238
  const trackRef = useRef<HTMLDivElement>(null)
214
239
  const thumbRef = useRef<SliderThumbMethods>(null)
215
240
  // --- interpret props ---
216
- const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
217
- const inactive = disabled || readonly
241
+ const inactive = disabled || readOnly
218
242
 
219
- const p = toFixed(scale.normalize(value, min, max) * 100)
220
- const rev = toFixed(100 - p)
221
- // NOTE
243
+ // Measured from the left or the top, as CSS places things:
222
244
  // normal -> normal (right)
223
- // vertical -> rev (up)
224
- // reverse -> rev (left)
245
+ // vertical -> reversed (up)
246
+ // reverse -> reversed (left)
225
247
  // vertical & reverse -> normal (down)
248
+ const vertical = orientation === 'vertical'
226
249
  const displayReversed = vertical !== reverse
227
- const percent = displayReversed ? rev : p
250
+ const percent = valuePercent(value, { min, max, scale }, displayReversed)
228
251
 
229
252
  // --- internal functions ---
230
253
  // The pointer is normalized against the track on both axes; only the one
@@ -237,18 +260,37 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
237
260
 
238
261
  useCheckSteps({ component: 'Slider', range: axis, keyboard, wheel })
239
262
 
263
+ const gesture = useChangeGesture(
264
+ value,
265
+ { onChangeStart, onChangeEnd, changeEndDelay },
266
+ inactive,
267
+ )
268
+ const change = useCallback(
269
+ (next: number) => {
270
+ gesture.changed(next)
271
+ onChange?.(next)
272
+ },
273
+ [gesture, onChange],
274
+ )
275
+
240
276
  const handleKeyDown = useCallback(
241
277
  (event: React.KeyboardEvent<HTMLDivElement>) => {
242
- const key = event.key
243
- if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
244
- return
278
+ const direction = arrowKeyDirection(event.key)
279
+ if (direction === null) return
245
280
  event.preventDefault()
246
281
  if (!keyboard || !onChange || inactive) return
247
- let direction = key === 'ArrowRight' || key === 'ArrowUp' ? 1 : -1
248
- if (reverse) direction *= -1
249
- onChange(applyDelta(value, direction, keyboard, axis, event))
282
+ gesture.pulse('keyboard')
283
+ change(
284
+ applyDelta(
285
+ value,
286
+ reverse ? -direction : direction,
287
+ keyboard,
288
+ axis,
289
+ event,
290
+ ),
291
+ )
250
292
  },
251
- [keyboard, onChange, inactive, reverse, value, axis],
293
+ [keyboard, onChange, inactive, reverse, value, axis, gesture, change],
252
294
  )
253
295
 
254
296
  // --- hooks ---
@@ -257,43 +299,40 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
257
299
  const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
258
300
  axis,
259
301
  baseElementRef: trackRef,
260
- cursor: inactive ? undefined : externalStyles.cursor,
302
+ cursor: inactive ? undefined : dragCursor,
261
303
  shouldStart: () => !inactive,
262
304
  sensitivity: (state) =>
263
305
  selectModifier(dragSensitivity, state.event).value,
264
306
  updateOnPointerDown: true,
265
307
  onChange: (v) => {
266
308
  if (inactive) return
267
- onChange?.(valueOf(v))
309
+ change(valueOf(v))
268
310
  },
269
- onDragStart: (v) => {
311
+ onDragStart: () => {
270
312
  if (inactive) return
313
+ gesture.hold('pointer')
271
314
  thumbRef.current?.focus()
272
- onDragStart?.(valueOf(v))
273
- },
274
- onDragEnd: (v) => {
275
- if (inactive) return
276
- onDragEnd?.(valueOf(v))
277
315
  },
316
+ onDragEnd: () => gesture.end(),
278
317
  })
279
318
 
280
319
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
281
320
  if (!wheel || !onChange || inactive) return
321
+ // A notch the slider does not read — a sideways scroll on a vertical
322
+ // slider — is left to the page rather than swallowed.
323
+ const direction = wheelDirection(event, { horizontal: !vertical })
324
+ if (direction === null) return
282
325
  event.preventDefault()
283
- let direction
284
- if (!vertical && event.deltaX !== 0) {
285
- direction = event.deltaX > 0 ? 1 : -1
286
- } else {
287
- if (event.deltaY === 0) return
288
- direction = event.deltaY > 0 ? -1 : 1
289
- }
290
- if (reverse) direction *= -1
291
- onChange(applyDelta(value, direction, wheel, axis, event))
326
+ gesture.pulse('wheel')
327
+ change(
328
+ applyDelta(value, reverse ? -direction : direction, wheel, axis, event),
329
+ )
292
330
  }, WHEEL_OPTIONS)
293
331
 
294
332
  // Composed once, so React attaches the refs a single time instead of
295
333
  // detaching and re-attaching on every render.
296
334
  const rootRefCallback = useComposedRefs<HTMLDivElement>(
335
+ forwardedRef,
297
336
  dragRefCallback,
298
337
  wheelRefCallback,
299
338
  )
@@ -305,10 +344,10 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
305
344
  max,
306
345
  step,
307
346
  scale,
308
- vertical,
347
+ orientation,
309
348
  reverse,
310
349
  disabled,
311
- readonly,
350
+ readOnly,
312
351
  onChange,
313
352
  percent,
314
353
  trackRef,
@@ -320,16 +359,16 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
320
359
  max,
321
360
  step,
322
361
  scale,
323
- vertical,
362
+ orientation,
324
363
  reverse,
325
364
  disabled,
326
- readonly,
365
+ readOnly,
327
366
  onChange,
328
367
  percent,
329
368
  ],
330
369
  )
331
370
 
332
- useImperativeHandle(forwardedRef, () => {
371
+ useImperativeHandle(actionsRef, () => {
333
372
  return {
334
373
  focus() {
335
374
  if (!disabled) thumbRef.current?.focus()
@@ -352,9 +391,9 @@ export const Root = /* @__PURE__ */ forwardRef<SliderMethods, Props>(
352
391
  // body — undoing the focus the drag just gave the input. Taking the
353
392
  // focus here keeps it inside, and `onFocus` passes it to the input.
354
393
  tabIndex={-1}
355
- data-orientation={vertical ? 'vertical' : 'horizontal'}
394
+ data-orientation={orientation}
356
395
  data-disabled={disabled ? '' : undefined}
357
- data-readonly={readonly ? '' : undefined}
396
+ data-readonly={readOnly ? '' : undefined}
358
397
  style={style}
359
398
  onPointerDown={onPointerDown}
360
399
  onKeyDown={(event) => {
@@ -394,4 +433,3 @@ export { type SliderThumbMethods, type SliderThumbProps } from './Thumb'
394
433
  export { type SliderTrackProps } from './Track'
395
434
  export { type SliderMarksProps } from './Marks'
396
435
  export { type SliderMarksOptionProps } from './MarksOption'
397
- export { type MarksOptions } from './type'
@@ -1,56 +1,34 @@
1
1
  import { ComponentPropsWithoutRef, CSSProperties, forwardRef } from 'react'
2
2
 
3
3
  import { useComposedRefs } from '../../compose-refs'
4
- import { cssLength } from '../_util/css-length'
5
4
  import { Placement } from '../_util/Placement'
6
5
 
7
6
  import { useXYPadContext } from './context'
8
7
 
9
- import type { CSSVariables } from '../../css-variables'
10
-
11
- export interface XYPadAreaProps {
12
- /** Sets `--width`; the size the theme gives it stands when omitted. */
13
- width?: number | string
14
- /** Sets `--height`; the size the theme gives it stands when omitted. */
15
- height?: number | string
16
- /** Sets `--color`, for the theme to colour the area with. */
17
- color?: string
18
-
19
- style?: CSSProperties & CSSVariables<'width' | 'height' | 'color'>
20
- }
21
-
22
- type Props = XYPadAreaProps &
23
- Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadAreaProps>
24
-
25
- export const Area = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
26
- function Area(
27
- { width, height, color, children, className, style, ...props },
28
- forwardedRef,
29
- ) {
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>(forwardedRef, areaRef)
35
-
36
- return (
37
- <div
38
- ref={composedRef}
39
- className={className}
40
- style={
41
- {
42
- '--color': color,
43
- '--width': cssLength(width),
44
- '--height': cssLength(height),
45
- // The thumb inside is placed against this box.
46
- position: 'relative',
47
- ...style,
48
- } as CSSProperties
49
- }
50
- {...props}
51
- >
52
- <Placement name="XYPad.Area">{children}</Placement>
53
- </div>
54
- )
55
- },
56
- )
8
+ export const Area = /* @__PURE__ */ forwardRef<
9
+ HTMLDivElement,
10
+ ComponentPropsWithoutRef<'div'>
11
+ >(function Area({ children, className, style, ...props }, forwardedRef) {
12
+ const { areaRef } = useXYPadContext()
13
+
14
+ // The area is what the pointer position is normalized against, so the
15
+ // context ref is composed with any ref the caller passed.
16
+ const composedRef = useComposedRefs<HTMLDivElement>(forwardedRef, areaRef)
17
+
18
+ return (
19
+ <div
20
+ ref={composedRef}
21
+ className={className}
22
+ style={
23
+ {
24
+ // The thumb inside is placed against this box.
25
+ position: 'relative',
26
+ ...style,
27
+ } as CSSProperties
28
+ }
29
+ {...props}
30
+ >
31
+ <Placement name="XYPad.Area">{children}</Placement>
32
+ </div>
33
+ )
34
+ })
@@ -1,16 +1,20 @@
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
 
11
+ import { type XYInput } from '@tremolo-ui/dom'
12
+ import { toXY } from '@tremolo-ui/dom/internal'
13
+
10
14
  import { useCheckPlacement } from '../_util/Placement'
11
15
  import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
12
16
 
13
- import { toXY, useXYPadContext, type XYInput } from './context'
17
+ import { useXYPadContext } from './context'
14
18
 
15
19
  import type { CSSVariables } from '../../css-variables'
16
20
 
@@ -39,6 +43,12 @@ export interface XYPadThumbProps {
39
43
  'aria-valuetext'?: XYInput<AriaAttributes['aria-valuetext']>
40
44
 
41
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>
42
52
  }
43
53
 
44
54
  export interface XYPadThumbMethods {
@@ -49,7 +59,7 @@ export interface XYPadThumbMethods {
49
59
  type Props = XYPadThumbProps &
50
60
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadThumbProps>
51
61
 
52
- export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
62
+ export const Thumb = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
53
63
  function Thumb(
54
64
  {
55
65
  color,
@@ -60,6 +70,7 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
60
70
  'aria-labelledby': ariaLabelledby,
61
71
  'aria-describedby': ariaDescribedby,
62
72
  'aria-valuetext': ariaValuetext,
73
+ actionsRef,
63
74
  ...props
64
75
  },
65
76
  forwardedRef,
@@ -72,7 +83,7 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
72
83
  max,
73
84
  step,
74
85
  disabled,
75
- readonly,
86
+ readOnly,
76
87
  onChange,
77
88
  percent,
78
89
  thumbRef,
@@ -97,15 +108,16 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
97
108
  },
98
109
  })
99
110
 
100
- useImperativeHandle(forwardedRef, methods, [disabled])
111
+ useImperativeHandle(actionsRef, methods, [disabled])
101
112
  // Root focuses the thumb when a drag starts, wherever the user placed it.
102
113
  useImperativeHandle(thumbRef, methods, [disabled])
103
114
 
104
115
  return (
105
116
  <div
117
+ ref={forwardedRef}
106
118
  className={className}
107
119
  data-disabled={disabled ? '' : undefined}
108
- data-readonly={readonly ? '' : undefined}
120
+ data-readonly={readOnly ? '' : undefined}
109
121
  {...props}
110
122
  style={{
111
123
  ...{ '--color': color },
@@ -133,14 +145,14 @@ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
133
145
  max={max[axis]}
134
146
  step={step[axis]}
135
147
  disabled={disabled}
136
- aria-readonly={readonly}
148
+ aria-readonly={readOnly}
137
149
  aria-orientation={axis === 0 ? 'horizontal' : 'vertical'}
138
150
  aria-label={labels[axis] ?? (axis === 0 ? 'x' : 'y')}
139
151
  aria-labelledby={labelledby[axis]}
140
152
  aria-describedby={describedby[axis]}
141
153
  aria-valuetext={valueText[axis]}
142
154
  onChange={(event) => {
143
- if (readonly) {
155
+ if (readOnly) {
144
156
  event.currentTarget.value = String(value[axis])
145
157
  return
146
158
  }
@@ -1,12 +1,10 @@
1
1
  import { createContext, RefObject, useContext } from 'react'
2
2
 
3
- import { toXY, type XY, type XYInput } from '@tremolo-ui/dom'
3
+ import { type XY } from '@tremolo-ui/dom'
4
4
  import type { Scale } from '@tremolo-ui/functions'
5
5
 
6
6
  import type { XYPadThumbMethods } from './Thumb'
7
7
 
8
- export { toXY, type XY, type XYInput }
9
-
10
8
  export type XYPadContextValue = {
11
9
  value: XY<number>
12
10
  min: XY<number>
@@ -16,7 +14,7 @@ export type XYPadContextValue = {
16
14
  scale: XY<Scale>
17
15
  reverse: XY<boolean>
18
16
  disabled: boolean
19
- readonly: boolean
17
+ readOnly: boolean
20
18
  onChange?: (value: XY<number>) => void
21
19
 
22
20
  /**