@tremolo-ui/react 0.5.0 → 0.7.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 (86) 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 +1669 -541
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1571 -486
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1571 -486
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1640 -538
  16. package/dist/index.js.map +1 -1
  17. package/package.json +29 -27
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/DropZone/index.tsx +100 -0
  20. package/src/components/FileInput/Trigger.tsx +39 -0
  21. package/src/components/FileInput/context.tsx +24 -0
  22. package/src/components/FileInput/index.tsx +144 -0
  23. package/src/components/Knob/ActiveLine.tsx +7 -6
  24. package/src/components/Knob/InactiveLine.tsx +9 -10
  25. package/src/components/Knob/SVGRoot.tsx +5 -3
  26. package/src/components/Knob/Thumb.tsx +33 -11
  27. package/src/components/Knob/context.tsx +17 -1
  28. package/src/components/Knob/index.tsx +156 -60
  29. package/src/components/NumberInput/DecrementStepper.tsx +3 -10
  30. package/src/components/NumberInput/IncrementStepper.tsx +3 -10
  31. package/src/components/NumberInput/InputField.tsx +185 -108
  32. package/src/components/NumberInput/Stepper.tsx +120 -84
  33. package/src/components/NumberInput/StepperButton.tsx +100 -0
  34. package/src/components/NumberInput/context.tsx +34 -8
  35. package/src/components/NumberInput/index.tsx +270 -82
  36. package/src/components/Piano/index.tsx +363 -200
  37. package/src/components/PointsEditor/Background.tsx +13 -10
  38. package/src/components/PointsEditor/Container.tsx +83 -29
  39. package/src/components/PointsEditor/Point.tsx +241 -79
  40. package/src/components/PointsEditor/SelectionBox.tsx +48 -0
  41. package/src/components/PointsEditor/context.tsx +78 -10
  42. package/src/components/PointsEditor/index.tsx +440 -41
  43. package/src/components/Slider/Marks.tsx +48 -19
  44. package/src/components/Slider/MarksOption.tsx +64 -45
  45. package/src/components/Slider/Thumb.tsx +107 -55
  46. package/src/components/Slider/Track.tsx +66 -62
  47. package/src/components/Slider/context.tsx +4 -2
  48. package/src/components/Slider/index.tsx +136 -77
  49. package/src/components/Slider/type.ts +29 -9
  50. package/src/components/XYPad/Area.tsx +46 -41
  51. package/src/components/XYPad/Thumb.tsx +131 -64
  52. package/src/components/XYPad/context.tsx +4 -1
  53. package/src/components/XYPad/index.tsx +183 -53
  54. package/src/components/_util/Placement.tsx +68 -0
  55. package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
  56. package/src/components/_util/css-length.ts +13 -0
  57. package/src/components/_util/decimal-digits.ts +23 -0
  58. package/src/components/_util/visually-hidden.ts +25 -0
  59. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  60. package/src/css-variables.ts +23 -0
  61. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  62. package/src/hooks/_internal/useCheckSteps.ts +169 -0
  63. package/src/hooks/useAnimationFrame.ts +17 -7
  64. package/src/hooks/useDrag.ts +51 -12
  65. package/src/hooks/useDragValue.ts +71 -11
  66. package/src/hooks/useDropZone.ts +78 -0
  67. package/src/hooks/useEventListener.ts +10 -6
  68. package/src/hooks/useLongPress.ts +42 -8
  69. package/src/hooks/useMIDIAccess.ts +82 -11
  70. package/src/hooks/useMIDIInput.ts +45 -13
  71. package/src/hooks/useMIDIMessage.ts +23 -5
  72. package/src/hooks/useWheel.ts +4 -1
  73. package/src/index.ts +45 -25
  74. package/src/input-event.ts +32 -0
  75. package/dist/index.css +0 -501
  76. package/src/Globals.d.ts +0 -3
  77. package/src/components/Knob/index.css +0 -81
  78. package/src/components/NumberInput/index.css +0 -121
  79. package/src/components/NumberInput/stepperButton.tsx +0 -90
  80. package/src/components/Piano/index.css +0 -90
  81. package/src/components/PointsEditor/index.css +0 -35
  82. package/src/components/Slider/index.css +0 -125
  83. package/src/components/XYPad/index.css +0 -60
  84. package/src/components/_util/index.ts +0 -28
  85. package/src/styles/global.css +0 -19
  86. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
@@ -1,27 +1,44 @@
1
- import clsx from 'clsx'
2
1
  import {
2
+ AriaAttributes,
3
3
  ComponentPropsWithoutRef,
4
- CSSProperties,
5
- ReactNode,
6
- Ref,
4
+ forwardRef,
7
5
  useImperativeHandle,
8
6
  useRef,
7
+ CSSProperties,
9
8
  } from 'react'
10
9
 
11
- import { useXYPadContext } from './context'
10
+ import { useCheckPlacement } from '../_util/Placement'
11
+ import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
12
+
13
+ import { toXY, useXYPadContext, type XYInput } from './context'
14
+
15
+ import type { CSSVariables } from '../../css-variables'
12
16
 
13
17
  export interface XYPadThumbProps {
14
18
  /**
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.
19
+ * Sets `--color`, for the theme to colour the thumb with. The size is not
20
+ * set here but by `--thumb-size` on `XYPad.Root`, so that the root can leave
21
+ * the matching room around the area.
17
22
  */
18
23
  color?: string
19
24
 
20
- wrapperClassName?: string
21
- wrapperStyle?: CSSProperties
25
+ /**
26
+ * The accessible name of each axis, as `[x, y]`. There are two range inputs
27
+ * inside the thumb, so each takes a name of its own; a single string names
28
+ * them both, which is rarely what you want.
29
+ */
30
+ 'aria-label'?: XYInput<AriaAttributes['aria-label']>
31
+ /** The ids of what labels each axis, as `[x, y]` or one for both. */
32
+ 'aria-labelledby'?: XYInput<AriaAttributes['aria-labelledby']>
33
+ /** The ids of what describes each axis, as `[x, y]` or one for both. */
34
+ 'aria-describedby'?: XYInput<AriaAttributes['aria-describedby']>
35
+ /**
36
+ * What the value of each axis means, when the number does not say it, as
37
+ * `[x, y]` or one for both.
38
+ */
39
+ 'aria-valuetext'?: XYInput<AriaAttributes['aria-valuetext']>
22
40
 
23
- children?: ReactNode
24
- ref?: Ref<XYPadThumbMethods>
41
+ style?: CSSProperties & CSSVariables<'color' | 'translate'>
25
42
  }
26
43
 
27
44
  export interface XYPadThumbMethods {
@@ -32,59 +49,109 @@ export interface XYPadThumbMethods {
32
49
  type Props = XYPadThumbProps &
33
50
  Omit<ComponentPropsWithoutRef<'div'>, keyof XYPadThumbProps>
34
51
 
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()
52
+ export const Thumb = /* @__PURE__ */ forwardRef<XYPadThumbMethods, Props>(
53
+ function Thumb(
54
+ {
55
+ color,
56
+ children,
57
+ className,
58
+ style,
59
+ 'aria-label': ariaLabel,
60
+ 'aria-labelledby': ariaLabelledby,
61
+ 'aria-describedby': ariaDescribedby,
62
+ 'aria-valuetext': ariaValuetext,
63
+ ...props
51
64
  },
52
- blur() {
53
- elementRef.current?.blur()
54
- },
55
- })
65
+ forwardedRef,
66
+ ) {
67
+ const xInputRef = useRef<HTMLInputElement>(null)
68
+ const yInputRef = useRef<HTMLInputElement>(null)
69
+ const {
70
+ value,
71
+ min,
72
+ max,
73
+ step,
74
+ disabled,
75
+ readonly,
76
+ onChange,
77
+ percent,
78
+ thumbRef,
79
+ } = useXYPadContext()
56
80
 
57
- useImperativeHandle(ref, methods, [])
58
- // Root focuses the thumb when a drag starts, wherever the user placed it.
59
- useImperativeHandle(thumbRef, methods, [])
81
+ // The thumb is positioned against the area.
82
+ useCheckPlacement('XYPad.Thumb', 'XYPad.Area')
60
83
 
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
- }
84
+ // Per axis, since the thumb holds one input for each.
85
+ const labels = toXY(ariaLabel)
86
+ const labelledby = toXY(ariaLabelledby)
87
+ const describedby = toXY(ariaDescribedby)
88
+ const valueText = toXY(ariaValuetext)
89
+
90
+ const methods = () => ({
91
+ focus() {
92
+ if (!disabled) xInputRef.current?.focus()
93
+ },
94
+ blur() {
95
+ xInputRef.current?.blur()
96
+ yInputRef.current?.blur()
97
+ },
98
+ })
99
+
100
+ useImperativeHandle(forwardedRef, methods, [disabled])
101
+ // Root focuses the thumb when a drag starts, wherever the user placed it.
102
+ useImperativeHandle(thumbRef, methods, [disabled])
103
+
104
+ return (
105
+ <div
106
+ className={className}
107
+ data-disabled={disabled ? '' : undefined}
108
+ data-readonly={readonly ? '' : undefined}
109
+ {...props}
110
+ style={{
111
+ ...{ '--color': color },
112
+ // Not a style but the mechanics of the position below: the thumb is
113
+ // placed by a percentage of the area, which needs it out of flow and
114
+ // measured from its own centre.
115
+ position: 'absolute',
116
+ translate: 'var(--translate, -50% -50%)',
117
+ zIndex: 100,
118
+ ...style,
119
+ // Where the thumb sits is the component's decision, not a style: a
120
+ // `left` from the caller would take it off the area, so it is written
121
+ // after theirs.
122
+ left: `${percent[0]}%`,
123
+ top: `${percent[1]}%`,
124
+ }}
125
+ >
126
+ {([0, 1] as const).map((axis) => (
127
+ <VisuallyHiddenRangeInput
128
+ key={axis}
129
+ ref={axis === 0 ? xInputRef : yInputRef}
130
+ data-axis={axis === 0 ? 'x' : 'y'}
131
+ value={value[axis]}
132
+ min={min[axis]}
133
+ max={max[axis]}
134
+ step={step[axis]}
135
+ disabled={disabled}
136
+ aria-readonly={readonly}
137
+ aria-orientation={axis === 0 ? 'horizontal' : 'vertical'}
138
+ aria-label={labels[axis] ?? (axis === 0 ? 'x' : 'y')}
139
+ aria-labelledby={labelledby[axis]}
140
+ aria-describedby={describedby[axis]}
141
+ aria-valuetext={valueText[axis]}
142
+ onChange={(event) => {
143
+ if (readonly) {
144
+ event.currentTarget.value = String(value[axis])
145
+ return
146
+ }
147
+ const next = [...value] as [number, number]
148
+ next[axis] = event.currentTarget.valueAsNumber
149
+ onChange?.(next)
150
+ }}
151
+ />
152
+ ))}
153
+ {children}
154
+ </div>
155
+ )
156
+ },
157
+ )
@@ -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,
@@ -10,26 +10,31 @@ import {
10
10
  useRef,
11
11
  } from 'react'
12
12
 
13
- import type { AxisOptions } from '@tremolo-ui/dom'
14
13
  import {
15
14
  applyDelta,
16
- linearScale,
17
- toFixed,
15
+ type AxisOptions,
18
16
  InputEventOption,
19
- type Scale,
20
- } from '@tremolo-ui/functions'
17
+ ModifierState,
18
+ type ModifierValue,
19
+ selectModifier,
20
+ } from '@tremolo-ui/dom'
21
+ import { linearScale, type Scale, toFixed } from '@tremolo-ui/functions'
21
22
 
23
+ import { useComposedRefs } from '../../compose-refs'
24
+ import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
22
25
  import { useDragValue } from '../../hooks/useDragValue'
23
26
  import { useWheel } from '../../hooks/useWheel'
24
- import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
25
- import { useComposedRefs } from '../_util/composeRefs'
27
+ import {
28
+ DEFAULT_DRAG_SENSITIVITY,
29
+ DEFAULT_KEYBOARD_OPTIONS,
30
+ DEFAULT_WHEEL_OPTIONS,
31
+ } from '../../input-event'
26
32
 
27
33
  import { Area } from './Area'
28
34
  import { toXY, XY, XYInput, XYPadProvider } from './context'
29
35
  import { Thumb, XYPadThumbMethods } from './Thumb'
30
36
 
31
37
  const defaultExternalStyles: XYPadProps['externalStyles'] = {
32
- userSelectNone: true,
33
38
  cursor: 'pointer',
34
39
  }
35
40
 
@@ -40,13 +45,23 @@ const defaultExternalStyles: XYPadProps['externalStyles'] = {
40
45
  * value applies to both axes.
41
46
  */
42
47
  export interface XYPadProps {
48
+ /** The current value as `[x, y]`. The pad shows only this, so update it from `onChange`. */
43
49
  value: XY<number>
50
+ /** The value at the start of each axis, as `[x, y]` or one number for both. */
44
51
  min: XYInput<number>
52
+ /** The value at the end of each axis, as `[x, y]` or one number for both. */
45
53
  max: XYInput<number>
46
54
 
55
+ /**
56
+ * Granularity of each axis, as `[x, y]` or one number for both. A drag, the
57
+ * wheel and the arrow keys snap the value to multiples of it.
58
+ *
59
+ * @default 1
60
+ */
47
61
  step?: XYInput<number>
48
62
  /**
49
- * How the value of each axis is distributed across the travel.
63
+ * How the value of each axis is distributed across the travel, as `[x, y]`
64
+ * or one scale for both.
50
65
  *
51
66
  * Pick one of the scales from `@tremolo-ui/functions`: `linearScale`,
52
67
  * `exponentialScale`, `curveScale(n)`, `symmetricSkewScale(n)`, or
@@ -55,29 +70,88 @@ export interface XYPadProps {
55
70
  * @default linearScale
56
71
  */
57
72
  scale?: XYInput<Scale>
73
+ /**
74
+ * Grow an axis the other way, as `[x, y]` or one for both. By default x grows
75
+ * rightwards and y downwards. The arrow keys follow the direction on screen.
76
+ *
77
+ * @default false
78
+ */
58
79
  reverse?: XYInput<boolean>
59
80
 
60
81
  /**
61
- * wheel control option. Shift selects the x axis.
62
- * If null, no event will be triggered
82
+ * How much one notch of the wheel moves the value. It only acts while the
83
+ * focus is inside, so that scrolling the page past the pad leaves it alone.
84
+ * Scrolling sideways, or with shift held, moves x; otherwise it moves y.
85
+ *
86
+ * `['raw', n]` moves the value by `n`, and `['normalized', n]` by `n` of the
87
+ * range of that axis. The result is snapped to `step`, except for an amount
88
+ * set on a modifier key (`{ default: …, alt: … }`). `null` turns the wheel
89
+ * off.
90
+ *
91
+ * @default ['raw', 1]
63
92
  */
64
- wheel?: InputEventOption | null
93
+ wheel?: ModifierValue<InputEventOption> | null
65
94
  /**
66
- * keyboard control option
67
- * If null, no event will be triggered
95
+ * How much a drag moves the value, per modifier key.
96
+ *
97
+ * `1` is the pointer position itself, which is what a drag normally is here.
98
+ * **Anything else turns the drag relative**: `0.1` makes the same movement
99
+ * cover a tenth of the travel, so the value stops following the pointer and
100
+ * starts moving a tenth as fast. Shift is bound to `0.1` by default, to
101
+ * match what it does on the arrow keys.
102
+ *
103
+ * Pressing or releasing the key mid-drag does not disturb the value: the
104
+ * travel so far is kept and the new sensitivity applies from there. **The
105
+ * pointer and the value stay apart for the rest of the drag** — snapping
106
+ * them back together on release would move the value nobody asked to move.
107
+ *
108
+ * @default { default: 1, shift: 0.1 }
68
109
  */
69
- keyboard?: InputEventOption | null
110
+ dragSensitivity?: ModifierValue<number>
70
111
 
112
+ /**
113
+ * How much one arrow key press moves the value. Left and right move x, up
114
+ * and down move y.
115
+ *
116
+ * `['raw', n]` moves the value by `n`, and `['normalized', n]` by `n` of the
117
+ * range of that axis. The result is snapped to `step`, except for an amount
118
+ * set on a modifier key, which is what lets shift move off the grid. `null`
119
+ * turns the arrow keys off.
120
+ *
121
+ * The default moves by 1, and by 0.1 with shift. With a `step` above 1, raise
122
+ * the amount to match: 1 would round straight back to where it started, and
123
+ * a development build warns about it.
124
+ *
125
+ * @default { default: ['raw', 1], shift: ['raw', 0.1] }
126
+ */
127
+ keyboard?: ModifierValue<InputEventOption> | null
128
+
129
+ /**
130
+ * The cursor to show while dragging. It is set on the dragged element, so it
131
+ * stays while the pointer is outside the pad.
132
+ *
133
+ * @default { cursor: 'pointer' }
134
+ */
71
135
  externalStyles?: {
72
- userSelectNone?: boolean
73
- cursor?: Cursor
136
+ cursor?: CSSProperties['cursor']
74
137
  }
75
138
 
139
+ /**
140
+ * Make the pad unchangeable and remove its thumb from the tab order.
141
+ * The parts carry `data-disabled` while it is set.
142
+ */
76
143
  disabled?: boolean
144
+ /**
145
+ * Make the pad unchangeable while leaving its thumb focusable.
146
+ * The parts carry `data-readonly` while it is set.
147
+ */
77
148
  readonly?: boolean
78
149
 
150
+ /** Called with the new value when a drag, the wheel or an arrow key moves it. */
79
151
  onChange?: (value: XY<number>) => void
152
+ /** Called when a drag starts, with the value where the area was pressed. */
80
153
  onDragStart?: (value: XY<number>) => void
154
+ /** Called when the drag ends, with the value it ended on. */
81
155
  onDragEnd?: (value: XY<number>) => void
82
156
 
83
157
  /**
@@ -109,7 +183,7 @@ type Props = XYPadProps &
109
183
  */
110
184
  const WHEEL_OPTIONS = { requireFocus: true }
111
185
 
112
- export const Root = forwardRef<XYPadMethods, Props>(
186
+ export const Root = /* @__PURE__ */ forwardRef<XYPadMethods, Props>(
113
187
  (
114
188
  {
115
189
  value,
@@ -118,8 +192,9 @@ export const Root = forwardRef<XYPadMethods, Props>(
118
192
  step: _step = 1,
119
193
  scale: _scale = linearScale,
120
194
  reverse: _reverse = false,
121
- wheel = ['raw', 1],
122
- keyboard = ['raw', 1],
195
+ wheel = DEFAULT_WHEEL_OPTIONS,
196
+ keyboard = DEFAULT_KEYBOARD_OPTIONS,
197
+ dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
123
198
  className,
124
199
  style,
125
200
  externalStyles: _externalStyles,
@@ -144,6 +219,7 @@ export const Root = forwardRef<XYPadMethods, Props>(
144
219
 
145
220
  // --- interpret props ---
146
221
  const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
222
+ const inactive = disabled || readonly
147
223
 
148
224
  const min = useMemo(() => toXY(_min), [_min])
149
225
  const max = useMemo(() => toXY(_max), [_max])
@@ -177,33 +253,61 @@ export const Root = forwardRef<XYPadMethods, Props>(
177
253
  [min, max, step, scale, reverse],
178
254
  )
179
255
 
256
+ useCheckSteps({
257
+ component: 'XYPad',
258
+ axis: 'x',
259
+ range: axis[0],
260
+ keyboard,
261
+ wheel,
262
+ })
263
+ useCheckSteps({
264
+ component: 'XYPad',
265
+ axis: 'y',
266
+ range: axis[1],
267
+ keyboard,
268
+ wheel,
269
+ })
270
+
180
271
  const withAxis = useCallback(
181
272
  (axis: 0 | 1, next: number): XY<number> =>
182
- axis == 0 ? [next, value[1]] : [value[0], next],
273
+ axis === 0 ? [next, value[1]] : [value[0], next],
183
274
  [value],
184
275
  )
185
276
 
186
277
  /** @param i 0 = x, 1 = y */
187
278
  const nudge = useCallback(
188
- (i: 0 | 1, direction: number, option: InputEventOption): XY<number> =>
189
- withAxis(i, applyDelta(value[i], direction, option, axis[i])),
279
+ (
280
+ i: 0 | 1,
281
+ direction: number,
282
+ option: ModifierValue<InputEventOption>,
283
+ modifiers: ModifierState,
284
+ ): XY<number> =>
285
+ withAxis(
286
+ i,
287
+ applyDelta(value[i], direction, option, axis[i], modifiers),
288
+ ),
190
289
  [value, axis, withAxis],
191
290
  )
192
291
 
193
292
  const handleKeyDown = useCallback(
194
293
  (event: React.KeyboardEvent<HTMLDivElement>) => {
195
- if (!onChange || readonly || !keyboard) return
196
294
  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
- }
295
+ if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
296
+ return
297
+
298
+ // The key picks the axis, whichever of the two inputs holds the
299
+ // focus: the pad is one control to the person moving it, and the focus
300
+ // lands on the x input, so reading the axis off the input would leave
301
+ // the y axis with no keys at all.
302
+ const i: 0 | 1 = key === 'ArrowRight' || key === 'ArrowLeft' ? 0 : 1
303
+ event.preventDefault()
304
+ if (!onChange || inactive || !keyboard) return
305
+ let direction = 1
306
+ if (key === 'ArrowLeft' || key === 'ArrowUp') direction *= -1
307
+ if (reverse[i]) direction *= -1
308
+ onChange(nudge(i, direction, keyboard, event))
205
309
  },
206
- [onChange, readonly, keyboard, reverse, nudge],
310
+ [onChange, inactive, keyboard, reverse, nudge],
207
311
  )
208
312
 
209
313
  // --- hooks ---
@@ -211,33 +315,41 @@ export const Root = forwardRef<XYPadMethods, Props>(
211
315
  const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
212
316
  axis,
213
317
  baseElementRef: areaRef,
318
+ sensitivity: (state) =>
319
+ selectModifier(dragSensitivity, state.event).value,
214
320
  updateOnPointerDown: true,
215
- cursor: readonly ? undefined : externalStyles.cursor,
321
+ cursor: inactive ? undefined : externalStyles.cursor,
322
+ shouldStart: () => !inactive,
216
323
  onChange: (v) => {
217
- if (readonly) return
324
+ if (inactive) return
218
325
  onChange?.(v)
219
326
  },
220
327
  onDragStart: (v) => {
221
- if (readonly) return
222
- if (externalStyles.userSelectNone) addUserSelectNone()
328
+ if (inactive) return
223
329
  thumbRef.current?.focus()
224
330
  onDragStart?.(v)
225
331
  },
226
332
  onDragEnd: (v) => {
227
- if (readonly) return
228
- if (externalStyles.userSelectNone) removeUserSelectNone()
333
+ if (inactive) return
229
334
  onDragEnd?.(v)
230
335
  },
231
336
  })
232
337
 
233
338
  const wheelRefCallback = useWheel<HTMLDivElement>((event) => {
234
- if (!onChange || readonly || !wheel) return
235
- const i: 0 | 1 = event.shiftKey ? 0 : 1
339
+ if (!onChange || inactive || !wheel) return
340
+ // Browsers turn shift+wheel into horizontal scrolling: `deltaY` comes
341
+ // out empty and `deltaX` carries the movement. Reading whichever axis
342
+ // moved keeps shift working as the x-axis modifier — and picks up a
343
+ // trackpad's own horizontal gesture, which never had a modifier.
344
+ const horizontal = event.deltaX !== 0
345
+ const delta = horizontal ? event.deltaX : event.deltaY
346
+ if (delta === 0) return
347
+ const i: 0 | 1 = horizontal || event.shiftKey ? 0 : 1
236
348
  event.preventDefault()
237
349
  let direction = 1
238
- if (event.deltaY < 0) direction *= -1
350
+ if (delta < 0) direction *= -1
239
351
  if (reverse[i]) direction *= -1
240
- onChange(nudge(i, direction, wheel))
352
+ onChange(nudge(i, direction, wheel, event))
241
353
  }, WHEEL_OPTIONS)
242
354
 
243
355
  // Composed once, so React attaches the refs a single time instead of
@@ -258,34 +370,47 @@ export const Root = forwardRef<XYPadMethods, Props>(
258
370
  reverse,
259
371
  disabled,
260
372
  readonly,
373
+ onChange,
261
374
  percent,
262
375
  areaRef,
263
376
  thumbRef,
264
377
  }),
265
- [value, min, max, step, scale, reverse, disabled, readonly, percent],
378
+ [
379
+ value,
380
+ min,
381
+ max,
382
+ step,
383
+ scale,
384
+ reverse,
385
+ disabled,
386
+ readonly,
387
+ onChange,
388
+ percent,
389
+ ],
266
390
  )
267
391
 
268
392
  useImperativeHandle(forwardedRef, () => {
269
393
  return {
270
394
  focus() {
271
- thumbRef.current?.focus()
395
+ if (!disabled) thumbRef.current?.focus()
272
396
  },
273
397
  blur() {
274
398
  thumbRef.current?.blur()
275
399
  },
276
400
  original: rootRef,
277
401
  }
278
- }, [])
402
+ }, [disabled])
279
403
 
280
404
  return (
281
405
  <XYPadProvider value={context}>
282
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
406
+ {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the group handles pointer and keyboard input shared by its two range controls */}
283
407
  <div
284
- className={clsx('tremolo-xy-pad', className)}
408
+ className={className}
285
409
  ref={rootRefCallback}
410
+ role="group"
286
411
  tabIndex={-1}
287
- aria-disabled={disabled}
288
- aria-readonly={readonly}
412
+ data-disabled={disabled ? '' : undefined}
413
+ data-readonly={readonly ? '' : undefined}
289
414
  style={style}
290
415
  onPointerDown={onPointerDown}
291
416
  onKeyDown={(event) => {
@@ -293,7 +418,7 @@ export const Root = forwardRef<XYPadMethods, Props>(
293
418
  onKeyDown?.(event)
294
419
  }}
295
420
  onFocus={(event) => {
296
- thumbRef.current?.focus()
421
+ if (!disabled) thumbRef.current?.focus()
297
422
  onFocus?.(event)
298
423
  }}
299
424
  onBlur={(event) => {
@@ -320,4 +445,9 @@ export const XYPad = {
320
445
 
321
446
  export { type XYPadThumbProps, type XYPadThumbMethods } from './Thumb'
322
447
  export { type XYPadAreaProps } from './Area'
323
- export { useXYPadContext, type XY, type XYInput } from './context'
448
+ export {
449
+ useXYPadContext,
450
+ type XYPadContextValue,
451
+ type XY,
452
+ type XYInput,
453
+ } from './context'