@tremolo-ui/react 0.5.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 (76) 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 +1468 -534
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1103 -435
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1103 -435
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1457 -534
  16. package/dist/index.js.map +1 -1
  17. package/package.json +28 -26
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/Knob/ActiveLine.tsx +7 -6
  20. package/src/components/Knob/InactiveLine.tsx +9 -10
  21. package/src/components/Knob/SVGRoot.tsx +5 -3
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +17 -1
  24. package/src/components/Knob/index.tsx +148 -55
  25. package/src/components/NumberInput/DecrementStepper.tsx +8 -4
  26. package/src/components/NumberInput/IncrementStepper.tsx +9 -4
  27. package/src/components/NumberInput/InputField.tsx +188 -105
  28. package/src/components/NumberInput/Stepper.tsx +123 -72
  29. package/src/components/NumberInput/{stepperButton.tsx → StepperButton.tsx} +27 -22
  30. package/src/components/NumberInput/context.tsx +35 -8
  31. package/src/components/NumberInput/index.tsx +272 -75
  32. package/src/components/Piano/index.tsx +354 -188
  33. package/src/components/PointsEditor/Background.tsx +11 -2
  34. package/src/components/PointsEditor/Container.tsx +87 -27
  35. package/src/components/PointsEditor/Point.tsx +239 -79
  36. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  37. package/src/components/PointsEditor/context.tsx +78 -10
  38. package/src/components/PointsEditor/index.tsx +440 -39
  39. package/src/components/Slider/Marks.tsx +48 -16
  40. package/src/components/Slider/MarksOption.tsx +57 -43
  41. package/src/components/Slider/Thumb.tsx +108 -50
  42. package/src/components/Slider/Track.tsx +69 -58
  43. package/src/components/Slider/context.tsx +4 -2
  44. package/src/components/Slider/index.tsx +131 -65
  45. package/src/components/Slider/type.ts +26 -8
  46. package/src/components/XYPad/Area.tsx +45 -35
  47. package/src/components/XYPad/Thumb.tsx +135 -62
  48. package/src/components/XYPad/context.tsx +4 -1
  49. package/src/components/XYPad/index.tsx +180 -48
  50. package/src/components/_util/Placement.tsx +68 -0
  51. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  52. package/src/components/_util/css-length.ts +13 -0
  53. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  54. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  55. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  56. package/src/hooks/useAnimationFrame.ts +17 -7
  57. package/src/hooks/useDrag.ts +38 -8
  58. package/src/hooks/useDragValue.ts +54 -9
  59. package/src/hooks/useEventListener.ts +10 -6
  60. package/src/hooks/useLongPress.ts +42 -8
  61. package/src/hooks/useMIDIAccess.ts +82 -11
  62. package/src/hooks/useMIDIInput.ts +45 -13
  63. package/src/hooks/useMIDIMessage.ts +23 -5
  64. package/src/index.ts +38 -21
  65. package/src/input-event.ts +32 -0
  66. package/dist/index.css +0 -501
  67. package/src/Globals.d.ts +0 -3
  68. package/src/components/Knob/index.css +0 -81
  69. package/src/components/NumberInput/index.css +0 -121
  70. package/src/components/Piano/index.css +0 -90
  71. package/src/components/PointsEditor/index.css +0 -35
  72. package/src/components/Slider/index.css +0 -125
  73. package/src/components/XYPad/index.css +0 -60
  74. package/src/components/_util/index.ts +0 -28
  75. package/src/styles/global.css +0 -19
  76. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
@@ -1,27 +1,50 @@
1
- import clsx from 'clsx'
2
1
  import {
2
+ AriaAttributes,
3
3
  ComponentPropsWithoutRef,
4
4
  CSSProperties,
5
+ forwardRef,
5
6
  ReactNode,
6
- Ref,
7
7
  useImperativeHandle,
8
8
  useRef,
9
9
  } from 'react'
10
10
 
11
- import { useXYPadContext } from './context'
11
+ import { useCheckPlacement } from '../_util/Placement'
12
+ import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
13
+
14
+ import { toXY, useXYPadContext, type XYInput } from './context'
12
15
 
13
16
  export interface XYPadThumbProps {
14
17
  /**
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.
18
+ * Sets `--color`, for the theme to colour the thumb with. The size is not
19
+ * set here but by `--thumb-size` on `XYPad.Root`, so that the root can leave
20
+ * the matching room around the area.
17
21
  */
18
22
  color?: string
19
23
 
20
- wrapperClassName?: string
21
- wrapperStyle?: CSSProperties
22
-
24
+ className?: string
25
+ style?: CSSProperties
26
+ /**
27
+ * Rendered inside the thumb. The thumb is one element either way, so what
28
+ * is passed here is decoration on top of it rather than a replacement for
29
+ * it — `className` and `style` are how its own appearance is changed.
30
+ */
23
31
  children?: ReactNode
24
- ref?: Ref<XYPadThumbMethods>
32
+
33
+ /**
34
+ * The accessible name of each axis, as `[x, y]`. There are two range inputs
35
+ * inside the thumb, so each takes a name of its own; a single string names
36
+ * them both, which is rarely what you want.
37
+ */
38
+ 'aria-label'?: XYInput<AriaAttributes['aria-label']>
39
+ /** The ids of what labels each axis, as `[x, y]` or one for both. */
40
+ 'aria-labelledby'?: XYInput<AriaAttributes['aria-labelledby']>
41
+ /** The ids of what describes each axis, as `[x, y]` or one for both. */
42
+ 'aria-describedby'?: XYInput<AriaAttributes['aria-describedby']>
43
+ /**
44
+ * What the value of each axis means, when the number does not say it, as
45
+ * `[x, y]` or one for both.
46
+ */
47
+ 'aria-valuetext'?: XYInput<AriaAttributes['aria-valuetext']>
25
48
  }
26
49
 
27
50
  export interface XYPadThumbMethods {
@@ -32,59 +55,109 @@ export interface XYPadThumbMethods {
32
55
  type Props = XYPadThumbProps &
33
56
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadThumbProps>
34
57
 
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()
47
-
48
- const methods = () => ({
49
- focus() {
50
- elementRef.current?.focus()
58
+ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
59
+ function Thumb(
60
+ {
61
+ color,
62
+ children,
63
+ className,
64
+ style,
65
+ 'aria-label': ariaLabel,
66
+ 'aria-labelledby': ariaLabelledby,
67
+ 'aria-describedby': ariaDescribedby,
68
+ 'aria-valuetext': ariaValuetext,
69
+ ...props
51
70
  },
52
- blur() {
53
- elementRef.current?.blur()
54
- },
55
- })
71
+ forwardedRef,
72
+ ) {
73
+ const xInputRef = useRef<HTMLInputElement>(null)
74
+ const yInputRef = useRef<HTMLInputElement>(null)
75
+ const {
76
+ value,
77
+ min,
78
+ max,
79
+ step,
80
+ disabled,
81
+ readonly,
82
+ onChange,
83
+ percent,
84
+ thumbRef,
85
+ } = useXYPadContext()
56
86
 
57
- useImperativeHandle(ref, methods, [])
58
- // Root focuses the thumb when a drag starts, wherever the user placed it.
59
- useImperativeHandle(thumbRef, methods, [])
87
+ // The thumb is positioned against the area.
88
+ useCheckPlacement('XYPad.Thumb', 'XYPad.Area')
60
89
 
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
- }
90
+ // Per axis, since the thumb holds one input for each.
91
+ const labels = toXY(ariaLabel)
92
+ const labelledby = toXY(ariaLabelledby)
93
+ const describedby = toXY(ariaDescribedby)
94
+ const valueText = toXY(ariaValuetext)
95
+
96
+ const methods = () => ({
97
+ focus() {
98
+ if (!disabled) xInputRef.current?.focus()
99
+ },
100
+ blur() {
101
+ xInputRef.current?.blur()
102
+ yInputRef.current?.blur()
103
+ },
104
+ })
105
+
106
+ useImperativeHandle(forwardedRef, methods, [disabled])
107
+ // Root focuses the thumb when a drag starts, wherever the user placed it.
108
+ useImperativeHandle(thumbRef, methods, [disabled])
109
+
110
+ return (
111
+ <div
112
+ className={className}
113
+ data-disabled={disabled ? '' : undefined}
114
+ data-readonly={readonly ? '' : undefined}
115
+ {...props}
116
+ style={{
117
+ ...{ '--color': color },
118
+ // Not a style but the mechanics of the position below: the thumb is
119
+ // placed by a percentage of the area, which needs it out of flow and
120
+ // measured from its own centre.
121
+ position: 'absolute',
122
+ translate: 'var(--translate, -50% -50%)',
123
+ zIndex: 100,
124
+ ...style,
125
+ // Where the thumb sits is the component's decision, not a style: a
126
+ // `left` from the caller would take it off the area, so it is written
127
+ // after theirs.
128
+ left: `${percent[0]}%`,
129
+ top: `${percent[1]}%`,
130
+ }}
131
+ >
132
+ {([0, 1] as const).map((axis) => (
133
+ <VisuallyHiddenRangeInput
134
+ key={axis}
135
+ ref={axis === 0 ? xInputRef : yInputRef}
136
+ data-axis={axis === 0 ? 'x' : 'y'}
137
+ value={value[axis]}
138
+ min={min[axis]}
139
+ max={max[axis]}
140
+ step={step[axis]}
141
+ disabled={disabled}
142
+ aria-readonly={readonly}
143
+ aria-orientation={axis === 0 ? 'horizontal' : 'vertical'}
144
+ aria-label={labels[axis] ?? (axis === 0 ? 'x' : 'y')}
145
+ aria-labelledby={labelledby[axis]}
146
+ aria-describedby={describedby[axis]}
147
+ aria-valuetext={valueText[axis]}
148
+ onChange={(event) => {
149
+ if (readonly) {
150
+ event.currentTarget.value = String(value[axis])
151
+ return
152
+ }
153
+ const next = [...value] as [number, number]
154
+ next[axis] = event.currentTarget.valueAsNumber
155
+ onChange?.(next)
156
+ }}
157
+ />
158
+ ))}
159
+ {children}
160
+ </div>
161
+ )
162
+ },
163
+ )
@@ -17,6 +17,7 @@ export type XYPadContextValue = {
17
17
  reverse: XY<boolean>
18
18
  disabled: boolean
19
19
  readonly: boolean
20
+ onChange?: (value: XY<number>) => void
20
21
 
21
22
  /**
22
23
  * Position of the thumb, 0-100 per axis, already accounting for `reverse`.
@@ -29,7 +30,9 @@ export type XYPadContextValue = {
29
30
  thumbRef: RefObject<XYPadThumbMethods | null>
30
31
  }
31
32
 
32
- const XYPadContext = createContext<XYPadContextValue | null>(null)
33
+ const XYPadContext = /* @__PURE__ */ createContext<XYPadContextValue | null>(
34
+ null,
35
+ )
33
36
 
34
37
  export const XYPadProvider = XYPadContext.Provider
35
38
 
@@ -1,6 +1,6 @@
1
- import clsx from 'clsx'
2
1
  import {
3
2
  ComponentPropsWithoutRef,
3
+ CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
6
  Ref,
@@ -16,20 +16,27 @@ import {
16
16
  linearScale,
17
17
  toFixed,
18
18
  InputEventOption,
19
+ ModifierState,
20
+ type ModifierValue,
21
+ selectModifier,
19
22
  type Scale,
20
23
  } from '@tremolo-ui/functions'
21
24
 
25
+ import { useComposedRefs } from '../../compose-refs'
26
+ import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
22
27
  import { useDragValue } from '../../hooks/useDragValue'
23
28
  import { useWheel } from '../../hooks/useWheel'
24
- import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
25
- import { useComposedRefs } from '../_util/composeRefs'
29
+ import {
30
+ DEFAULT_DRAG_SENSITIVITY,
31
+ DEFAULT_KEYBOARD_OPTIONS,
32
+ DEFAULT_WHEEL_OPTIONS,
33
+ } from '../../input-event'
26
34
 
27
35
  import { Area } from './Area'
28
36
  import { toXY, XY, XYInput, XYPadProvider } from './context'
29
37
  import { Thumb, XYPadThumbMethods } from './Thumb'
30
38
 
31
39
  const defaultExternalStyles: XYPadProps['externalStyles'] = {
32
- userSelectNone: true,
33
40
  cursor: 'pointer',
34
41
  }
35
42
 
@@ -40,13 +47,23 @@ const defaultExternalStyles: XYPadProps['externalStyles'] = {
40
47
  * value applies to both axes.
41
48
  */
42
49
  export interface XYPadProps {
50
+ /** The current value as `[x, y]`. The pad shows only this, so update it from `onChange`. */
43
51
  value: XY<number>
52
+ /** The value at the start of each axis, as `[x, y]` or one number for both. */
44
53
  min: XYInput<number>
54
+ /** The value at the end of each axis, as `[x, y]` or one number for both. */
45
55
  max: XYInput<number>
46
56
 
57
+ /**
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
62
+ */
47
63
  step?: XYInput<number>
48
64
  /**
49
- * How the value of each axis is distributed across the travel.
65
+ * How the value of each axis is distributed across the travel, as `[x, y]`
66
+ * or one scale for both.
50
67
  *
51
68
  * Pick one of the scales from `@tremolo-ui/functions`: `linearScale`,
52
69
  * `exponentialScale`, `curveScale(n)`, `symmetricSkewScale(n)`, or
@@ -55,29 +72,88 @@ export interface XYPadProps {
55
72
  * @default linearScale
56
73
  */
57
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
+ */
58
81
  reverse?: XYInput<boolean>
59
82
 
60
83
  /**
61
- * wheel control option. Shift selects the x axis.
62
- * If null, no event will be triggered
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]
63
94
  */
64
- wheel?: InputEventOption | null
95
+ wheel?: ModifierValue<InputEventOption> | null
65
96
  /**
66
- * keyboard control option
67
- * If null, no event will be triggered
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 }
68
111
  */
69
- keyboard?: InputEventOption | null
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
 
152
+ /** Called with the new value when a drag, the wheel or an arrow key moves it. */
79
153
  onChange?: (value: XY<number>) => void
154
+ /** Called when a drag starts, with the value where the area was pressed. */
80
155
  onDragStart?: (value: XY<number>) => void
156
+ /** Called when the drag ends, with the value it ended on. */
81
157
  onDragEnd?: (value: XY<number>) => void
82
158
 
83
159
  /**
@@ -109,7 +185,7 @@ type Props = XYPadProps &
109
185
  */
110
186
  const WHEEL_OPTIONS = { requireFocus: true }
111
187
 
112
- export const Root = forwardRef<XYPadMethods, Props>(
188
+ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
113
189
  (
114
190
  {
115
191
  value,
@@ -118,8 +194,9 @@ export const Root = forwardRef<XYPadMethods, Props>(
118
194
  step: _step = 1,
119
195
  scale: _scale = linearScale,
120
196
  reverse: _reverse = false,
121
- wheel = ['raw', 1],
122
- keyboard = ['raw', 1],
197
+ wheel = DEFAULT_WHEEL_OPTIONS,
198
+ keyboard = DEFAULT_KEYBOARD_OPTIONS,
199
+ dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
123
200
  className,
124
201
  style,
125
202
  externalStyles: _externalStyles,
@@ -144,6 +221,7 @@ export const Root = forwardRef<XYPadMethods, Props>(
144
221
 
145
222
  // --- interpret props ---
146
223
  const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
224
+ const inactive = disabled || readonly
147
225
 
148
226
  const min = useMemo(() => toXY(_min), [_min])
149
227
  const max = useMemo(() => toXY(_max), [_max])
@@ -177,33 +255,61 @@ export const Root = forwardRef<XYPadMethods, Props>(
177
255
  [min, max, step, scale, reverse],
178
256
  )
179
257
 
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
+
180
273
  const withAxis = useCallback(
181
274
  (axis: 0 | 1, next: number): XY<number> =>
182
- axis == 0 ? [next, value[1]] : [value[0], next],
275
+ axis === 0 ? [next, value[1]] : [value[0], next],
183
276
  [value],
184
277
  )
185
278
 
186
279
  /** @param i 0 = x, 1 = y */
187
280
  const nudge = useCallback(
188
- (i: 0 | 1, direction: number, option: InputEventOption): XY<number> =>
189
- withAxis(i, applyDelta(value[i], direction, option, axis[i])),
281
+ (
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
+ ),
190
291
  [value, axis, withAxis],
191
292
  )
192
293
 
193
294
  const handleKeyDown = useCallback(
194
295
  (event: React.KeyboardEvent<HTMLDivElement>) => {
195
- if (!onChange || readonly || !keyboard) return
196
296
  const key = event.key
197
- if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
198
- const i: 0 | 1 = key == 'ArrowRight' || key == 'ArrowLeft' ? 0 : 1
199
- event.preventDefault()
200
- let direction = 1
201
- if (key == 'ArrowLeft' || key == 'ArrowUp') direction *= -1
202
- if (reverse[i]) direction *= -1
203
- onChange(nudge(i, direction, keyboard))
204
- }
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))
205
311
  },
206
- [onChange, readonly, keyboard, reverse, nudge],
312
+ [onChange, inactive, keyboard, reverse, nudge],
207
313
  )
208
314
 
209
315
  // --- hooks ---
@@ -211,33 +317,41 @@ export const Root = forwardRef<XYPadMethods, Props>(
211
317
  const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
212
318
  axis,
213
319
  baseElementRef: areaRef,
320
+ sensitivity: (state) =>
321
+ selectModifier(dragSensitivity, state.event).value,
214
322
  updateOnPointerDown: true,
215
- cursor: readonly ? undefined : externalStyles.cursor,
323
+ cursor: inactive ? undefined : externalStyles.cursor,
324
+ shouldStart: () => !inactive,
216
325
  onChange: (v) => {
217
- if (readonly) return
326
+ if (inactive) return
218
327
  onChange?.(v)
219
328
  },
220
329
  onDragStart: (v) => {
221
- if (readonly) return
222
- if (externalStyles.userSelectNone) addUserSelectNone()
330
+ if (inactive) return
223
331
  thumbRef.current?.focus()
224
332
  onDragStart?.(v)
225
333
  },
226
334
  onDragEnd: (v) => {
227
- if (readonly) return
228
- if (externalStyles.userSelectNone) removeUserSelectNone()
335
+ if (inactive) return
229
336
  onDragEnd?.(v)
230
337
  },
231
338
  })
232
339
 
233
340
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
234
- if (!onChange || readonly || !wheel) return
235
- const i: 0 | 1 = event.shiftKey ? 0 : 1
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
236
350
  event.preventDefault()
237
351
  let direction = 1
238
- if (event.deltaY < 0) direction *= -1
352
+ if (delta < 0) direction *= -1
239
353
  if (reverse[i]) direction *= -1
240
- onChange(nudge(i, direction, wheel))
354
+ onChange(nudge(i, direction, wheel, event))
241
355
  }, WHEEL_OPTIONS)
242
356
 
243
357
  // Composed once, so React attaches the refs a single time instead of
@@ -258,34 +372,47 @@ export const Root = forwardRef<XYPadMethods, Props>(
258
372
  reverse,
259
373
  disabled,
260
374
  readonly,
375
+ onChange,
261
376
  percent,
262
377
  areaRef,
263
378
  thumbRef,
264
379
  }),
265
- [value, min, max, step, scale, reverse, disabled, readonly, percent],
380
+ [
381
+ value,
382
+ min,
383
+ max,
384
+ step,
385
+ scale,
386
+ reverse,
387
+ disabled,
388
+ readonly,
389
+ onChange,
390
+ percent,
391
+ ],
266
392
  )
267
393
 
268
394
  useImperativeHandle(forwardedRef, () => {
269
395
  return {
270
396
  focus() {
271
- thumbRef.current?.focus()
397
+ if (!disabled) thumbRef.current?.focus()
272
398
  },
273
399
  blur() {
274
400
  thumbRef.current?.blur()
275
401
  },
276
402
  original: rootRef,
277
403
  }
278
- }, [])
404
+ }, [disabled])
279
405
 
280
406
  return (
281
407
  <XYPadProvider value={context}>
282
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
408
+ {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the group handles pointer and keyboard input shared by its two range controls */}
283
409
  <div
284
- className={clsx('tremolo-xy-pad', className)}
410
+ className={className}
285
411
  ref={rootRefCallback}
412
+ role="group"
286
413
  tabIndex={-1}
287
- aria-disabled={disabled}
288
- aria-readonly={readonly}
414
+ data-disabled={disabled ? '' : undefined}
415
+ data-readonly={readonly ? '' : undefined}
289
416
  style={style}
290
417
  onPointerDown={onPointerDown}
291
418
  onKeyDown={(event) => {
@@ -293,7 +420,7 @@ export const Root = forwardRef<XYPadMethods, Props>(
293
420
  onKeyDown?.(event)
294
421
  }}
295
422
  onFocus={(event) => {
296
- thumbRef.current?.focus()
423
+ if (!disabled) thumbRef.current?.focus()
297
424
  onFocus?.(event)
298
425
  }}
299
426
  onBlur={(event) => {
@@ -320,4 +447,9 @@ export const XYPad = {
320
447
 
321
448
  export { type XYPadThumbProps, type XYPadThumbMethods } from './Thumb'
322
449
  export { type XYPadAreaProps } from './Area'
323
- export { useXYPadContext, type XY, type XYInput } from './context'
450
+ export {
451
+ useXYPadContext,
452
+ type XYPadContextValue,
453
+ type XY,
454
+ type XYInput,
455
+ } from './context'