@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,36 +1,96 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, ReactNode, Ref } from 'react'
1
+ import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
3
2
 
4
- import { useComposedRefs } from '../_util/composeRefs'
3
+ import { useComposedRefs } from '../../compose-refs'
4
+ import { useDragValue } from '../../hooks/useDragValue'
5
+ import { useWheel } from '../../hooks/useWheel'
6
+ import { Placement } from '../_util/Placement'
5
7
 
6
8
  import { usePointsEditorContext } from './context'
9
+ import { AXIS } from './Point'
7
10
 
8
11
  export interface PointsEditorContainerProps {
9
12
  /** `<PointsEditor.Point />` goes here. */
10
13
  children?: ReactNode
11
- ref?: Ref<HTMLDivElement>
12
14
  }
13
15
 
14
- export function Container({
15
- children,
16
- className,
17
- ref,
18
- ...props
19
- }: PointsEditorContainerProps &
20
- Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorContainerProps>) {
21
- const containerRef = usePointsEditorContext((s) => s.containerRef)
22
-
23
- // The container is what the pointer position is normalized against, so the
24
- // context ref is composed with any ref the caller passed.
25
- const composedRef = useComposedRefs<HTMLDivElement>(ref, containerRef)
26
-
27
- return (
28
- <div
29
- ref={composedRef}
30
- className={clsx('tremolo-points-editor-container', className)}
31
- {...props}
32
- >
33
- {children}
34
- </div>
35
- )
36
- }
16
+ type Props = PointsEditorContainerProps &
17
+ Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorContainerProps>
18
+
19
+ export const Container = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
20
+ function Container({ children, className, style, ...props }, forwardedRef) {
21
+ const {
22
+ containerRef,
23
+ disabled,
24
+ selectable,
25
+ isPointElement,
26
+ nudgeFocusedPoint,
27
+ beginSelectionBox,
28
+ moveSelectionBox,
29
+ endSelectionBox,
30
+ } = usePointsEditorContext()
31
+ const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
32
+ axis: AXIS,
33
+ baseElementRef: containerRef,
34
+ // A press that landed on a point belongs to that point. Declining here
35
+ // rather than in onDragStart matters: by then the container would already
36
+ // have taken the pointer capture away from the point.
37
+ shouldStart: (event) =>
38
+ !disabled && !isPointElement(event.target as Element | null),
39
+ onDragStart: ([x, y], state) => {
40
+ beginSelectionBox({ x, y }, state.event)
41
+ },
42
+ onChange: ([x, y]) => moveSelectionBox({ x, y }),
43
+ onDragEnd: endSelectionBox,
44
+ })
45
+
46
+ // One listener for the whole editor rather than one per point: a wheel
47
+ // event only reaches what the cursor is over, and a point is a 16px
48
+ // target, so the focused point takes it from anywhere over the editor —
49
+ // the way it does for Slider and XYPad.
50
+ useWheel(
51
+ (event) => {
52
+ // Scrolling up moves the point towards y = 0; shift switches to x.
53
+ // Browsers turn shift+wheel into horizontal scrolling: `deltaY` comes
54
+ // out empty and `deltaX` carries the movement. Reading whichever axis
55
+ // moved keeps shift working as the x-axis modifier — and picks up a
56
+ // trackpad's own horizontal gesture, which never had a modifier.
57
+ const horizontal = event.deltaX !== 0
58
+ const delta = horizontal ? event.deltaX : event.deltaY
59
+ if (delta === 0) return
60
+ const axis = horizontal || event.shiftKey ? 'x' : 'y'
61
+ const direction = delta < 0 ? -1 : 1
62
+ if (nudgeFocusedPoint(axis, direction, event)) event.preventDefault()
63
+ },
64
+ { target: containerRef },
65
+ )
66
+
67
+ // The container is what the pointer position is normalized against, so the
68
+ // context ref is composed with any ref the caller passed.
69
+ // Only attached when there is a selection to draw: `createDrag` puts
70
+ // `touch-action: none` on whatever it holds, and an editor that cannot
71
+ // select has no reason to stop the page scrolling under a finger.
72
+ const composedRef = useComposedRefs<HTMLDivElement>(
73
+ forwardedRef,
74
+ containerRef,
75
+ selectable && !disabled ? dragRefCallback : undefined,
76
+ )
77
+
78
+ return (
79
+ <div
80
+ ref={composedRef}
81
+ className={className}
82
+ style={{
83
+ // The points inside are placed against this box, over the
84
+ // background and under the selection box.
85
+ position: 'absolute',
86
+ inset: 0,
87
+ zIndex: 10,
88
+ ...style,
89
+ }}
90
+ {...props}
91
+ >
92
+ <Placement name="PointsEditor.Container">{children}</Placement>
93
+ </div>
94
+ )
95
+ },
96
+ )
@@ -1,14 +1,30 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, useCallback, useState } from 'react'
1
+ import {
2
+ ComponentPropsWithoutRef,
3
+ CSSProperties,
4
+ ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ } from 'react'
3
11
 
4
- import { applyDelta, clamp, InputEventOption } from '@tremolo-ui/functions'
12
+ import {
13
+ applyDelta,
14
+ clamp,
15
+ type InputEventOption,
16
+ type ModifierState,
17
+ type ModifierValue,
18
+ selectModifier,
19
+ } from '@tremolo-ui/functions'
5
20
 
21
+ import { useComposedRefs } from '../../compose-refs'
6
22
  import { useDragValue } from '../../hooks/useDragValue'
7
- import { useWheel } from '../../hooks/useWheel'
8
- import { addUserSelectNone, removeUserSelectNone } from '../_util'
9
- import { useComposedRefs } from '../_util/composeRefs'
23
+ import { cssLength } from '../_util/css-length'
24
+ import { useCheckPlacement } from '../_util/Placement'
25
+ import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
10
26
 
11
- import { usePointsEditorContext } from './context'
27
+ import { type PointRegistration, usePointsEditorContext } from './context'
12
28
 
13
29
  export type PointBaseType = { x: number; y: number }
14
30
 
@@ -23,14 +39,32 @@ export function clampPoint(
23
39
  return { x: newX, y: newY }
24
40
  }
25
41
 
26
- export interface PointProps<T extends PointBaseType> {
42
+ export interface PointsEditorPointProps<T extends PointBaseType> {
43
+ /**
44
+ * Where the point is, as `{ x, y }` from 0 to 1 on each axis, with `y`
45
+ * growing downwards.
46
+ */
27
47
  value: T
48
+ /** Drawn inside the point: the theme's own dot stands when it is left out. */
49
+ children?: ReactNode
50
+ /**
51
+ * How the selection refers to this point. One is generated when it is left
52
+ * out, which lasts as long as the point is mounted — give your own if the
53
+ * selection has to survive a remount, or be recognised in your own state.
54
+ */
55
+ id?: string
56
+ /** The lowest position the point can take, per axis. An axis left out is 0. */
28
57
  min?: Partial<PointBaseType>
58
+ /** The highest position the point can take, per axis. An axis left out is 1. */
29
59
  max?: Partial<PointBaseType>
30
60
 
61
+ /** Width and height at once. Sets both `--width` and `--height`. */
31
62
  size?: number | string
63
+ /** Sets `--width`; the size the theme gives it stands when omitted. */
32
64
  width?: number | string
65
+ /** Sets `--height`; the size the theme gives it stands when omitted. */
33
66
  height?: number | string
67
+ /** Sets `--color`, for the theme to colour the point with. */
34
68
  color?: string
35
69
 
36
70
  /** Overrides the `disabled` of `PointsEditor.Root`. */
@@ -39,12 +73,26 @@ export interface PointProps<T extends PointBaseType> {
39
73
  readonly?: boolean
40
74
 
41
75
  /** Overrides the `wheel` of `PointsEditor.Root`. */
42
- wheel?: InputEventOption | null
76
+ wheel?: ModifierValue<InputEventOption> | null
43
77
  /** Overrides the `keyboard` of `PointsEditor.Root`. */
44
- keyboard?: InputEventOption | null
78
+ keyboard?: ModifierValue<InputEventOption> | null
45
79
 
80
+ /**
81
+ * The accessible name of each axis. There are two range inputs inside the
82
+ * point, so this takes one name per axis; a single string names them both.
83
+ */
84
+ 'aria-label'?: string | Partial<Record<'x' | 'y', string>>
85
+ /** What the value of each axis means, when the number does not say it. */
86
+ 'aria-valuetext'?: string | Partial<Record<'x' | 'y', string>>
87
+
88
+ /**
89
+ * Called with the new position when the point is dragged or moved by the
90
+ * arrow keys or the wheel, including when it moves along with a selection.
91
+ */
46
92
  onChange?: (value: PointBaseType) => void
93
+ /** Called when a drag on this point starts, with where the point is. */
47
94
  onDragStart?: (value: PointBaseType) => void
95
+ /** Called when that drag ends, with where the point is. */
48
96
  onDragEnd?: (value: PointBaseType) => void
49
97
  }
50
98
 
@@ -52,21 +100,36 @@ export interface PointProps<T extends PointBaseType> {
52
100
  * A point is placed by its position within the container, so its value is a
53
101
  * position: 0..1 on each axis, with y growing downwards.
54
102
  */
55
- const AXIS = { min: 0, max: 1 }
103
+ export const AXIS = { min: 0, max: 1 }
104
+
105
+ /**
106
+ * One setting for both axes, or one per axis. The point holds a range input
107
+ * for each, so anything that names or describes it comes in a pair.
108
+ */
109
+ function perAxis(
110
+ value: string | Partial<Record<'x' | 'y', string>> | undefined,
111
+ axis: 'x' | 'y',
112
+ ) {
113
+ return typeof value === 'string' ? value : value?.[axis]
114
+ }
56
115
 
57
116
  export function Point<T extends PointBaseType>({
58
117
  value,
118
+ children,
119
+ id: idProp,
59
120
  min,
60
121
  max,
61
122
  size,
62
- width = 16,
63
- height = 16,
123
+ width,
124
+ height,
64
125
  color,
65
126
 
66
127
  disabled: _disabled,
67
128
  readonly: _readonly,
68
129
  wheel: _wheel,
69
130
  keyboard: _keyboard,
131
+ 'aria-label': ariaLabel,
132
+ 'aria-valuetext': ariaValuetext,
70
133
 
71
134
  onChange,
72
135
  onDragStart,
@@ -76,8 +139,10 @@ export function Point<T extends PointBaseType>({
76
139
  style,
77
140
  onPointerDown,
78
141
  onKeyDown,
142
+ onFocus,
79
143
  ...props
80
- }: PointProps<T> & Omit<ComponentPropsWithoutRef<'div'>, keyof PointProps<T>>) {
144
+ }: PointsEditorPointProps<T> &
145
+ Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorPointProps<T>>) {
81
146
  const {
82
147
  containerRef,
83
148
  externalStyles,
@@ -85,69 +150,114 @@ export function Point<T extends PointBaseType>({
85
150
  readonly: rootReadonly,
86
151
  wheel: rootWheel,
87
152
  keyboard: rootKeyboard,
153
+ dragSensitivity,
154
+ selection,
155
+ registerPoint,
156
+ beginPointDrag,
157
+ movePointDrag,
158
+ nudgeSelection,
88
159
  } = usePointsEditorContext()
89
160
 
161
+ const generatedId = useId()
162
+ const id = idProp ?? generatedId
163
+ const selected = selection.includes(id)
164
+
90
165
  const disabled = _disabled ?? rootDisabled
91
166
  const readonly = _readonly ?? rootReadonly
167
+ const inactive = disabled || readonly
92
168
  // `null` means "no event" and has to survive the fallback, so `??` is not
93
169
  // enough: only an omitted prop inherits from the root.
94
170
  const wheel = _wheel === undefined ? rootWheel : _wheel
95
171
  const keyboard = _keyboard === undefined ? rootKeyboard : _keyboard
96
172
 
173
+ useCheckPlacement('PointsEditor.Point', 'PointsEditor.Container')
174
+
97
175
  // Compared against the focus below, so the point needs its own element.
98
176
  const [element, setElement] = useState<HTMLDivElement | null>(null)
177
+ const xInputRef = useRef<HTMLInputElement>(null)
178
+ const yInputRef = useRef<HTMLInputElement>(null)
179
+
180
+ // What the editor needs to move this point along with the rest of a
181
+ // selection. Rewritten after every render rather than kept in the registry
182
+ // itself: the value changes on every frame of a drag.
183
+ const registration = useRef<PointRegistration>({
184
+ value,
185
+ min,
186
+ max,
187
+ readonly: inactive,
188
+ onChange,
189
+ element,
190
+ wheel,
191
+ })
192
+ useEffect(() => {
193
+ registration.current = {
194
+ value,
195
+ min,
196
+ max,
197
+ readonly: inactive,
198
+ onChange,
199
+ element,
200
+ wheel,
201
+ }
202
+ })
203
+
204
+ useEffect(() => registerPoint(id, registration), [id, registerPoint])
205
+
206
+ /** Where the pointer was when the drag started, to measure the move from. */
207
+ const pointerOrigin = useRef<PointBaseType | null>(null)
99
208
 
100
209
  // The value is the position itself: no scaling, and no rounding to a step.
101
210
  const { refCallback: dragRefCallback, dragging } =
102
211
  useDragValue<HTMLDivElement>({
103
212
  axis: AXIS,
104
213
  baseElementRef: containerRef,
105
- cursor: readonly ? undefined : externalStyles.cursor,
214
+ sensitivity: (state) =>
215
+ selectModifier(dragSensitivity, state.event).value,
216
+ cursor: inactive ? undefined : externalStyles.cursor,
217
+ shouldStart: () => !disabled,
218
+ // The value is a move rather than a position: the point keeps the offset
219
+ // it was grabbed at, and everything else selected moves with it by the
220
+ // same amount.
106
221
  onChange: ([x, y]) => {
107
- if (readonly) return
108
-
109
- onChange?.(clampPoint({ x, y }, min, max))
222
+ const origin = pointerOrigin.current
223
+ if (!origin) return
224
+ movePointDrag({ x: x - origin.x, y: y - origin.y })
110
225
  },
111
- onDragStart: ([x, y]) => {
112
- if (readonly) return
113
- if (externalStyles.userSelectNone) addUserSelectNone()
226
+ onDragStart: ([x, y], state) => {
227
+ // The press decides the selection before the snapshot is taken, so it
228
+ // happens here rather than in an onPointerDown: a native listener on
229
+ // the element runs before React's, and the two would disagree.
230
+ beginPointDrag(id, state.event)
231
+ pointerOrigin.current = { x, y }
232
+ xInputRef.current?.focus()
114
233
 
115
- onDragStart?.(clampPoint({ x, y }, min, max))
234
+ if (inactive) return
235
+ onDragStart?.(clampPoint(value, min, max))
116
236
  },
117
- onDragEnd: ([x, y]) => {
118
- if (readonly) return
119
- if (externalStyles.userSelectNone) removeUserSelectNone()
237
+ onDragEnd: () => {
238
+ pointerOrigin.current = null
120
239
 
121
- onDragEnd?.(clampPoint({ x, y }, min, max))
240
+ if (inactive) return
241
+ onDragEnd?.(clampPoint(value, min, max))
122
242
  },
123
243
  })
124
244
 
125
245
  const nudge = useCallback(
126
- (axis: 'x' | 'y', direction: number, option: InputEventOption) => {
127
- const next = applyDelta(value[axis], direction, option, AXIS)
128
- onChange?.(clampPoint({ ...value, [axis]: next }, min, max))
129
- },
130
- [value, min, max, onChange],
131
- )
132
-
133
- // The listener sits on the container rather than on the point: a wheel event
134
- // only reaches what the cursor is over, and a point is a 16px target. Every
135
- // point sees the event and the focused one acts, so the wheel works anywhere
136
- // over the editor, the way it does for Slider and XYPad.
137
- //
138
- // The focus test is an identity check, not `contains`: with `contains` every
139
- // point would match the container's focus and they would all move at once.
140
- useWheel(
141
- (event) => {
142
- if (!onChange || readonly || !wheel) return
143
- if (!element || element.ownerDocument.activeElement !== element) return
144
- event.preventDefault()
145
- // Scrolling up moves the point towards y = 0; shift switches to x.
146
- const axis = event.shiftKey ? 'x' : 'y'
147
- const direction = event.deltaY < 0 ? -1 : 1
148
- nudge(axis, direction, wheel)
246
+ (
247
+ axis: 'x' | 'y',
248
+ direction: number,
249
+ option: ModifierValue<InputEventOption>,
250
+ modifiers: ModifierState,
251
+ ) => {
252
+ const next = applyDelta(value[axis], direction, option, AXIS, modifiers)
253
+ // As a move, so that the rest of the selection comes along and the whole
254
+ // group stops together at the edge.
255
+ nudgeSelection(id, {
256
+ x: axis === 'x' ? next - value.x : 0,
257
+ y: axis === 'y' ? next - value.y : 0,
258
+ })
149
259
  },
150
- { target: containerRef },
260
+ [value, id, nudgeSelection],
151
261
  )
152
262
 
153
263
  const refCallback = useComposedRefs<HTMLDivElement>(
@@ -156,47 +266,97 @@ export function Point<T extends PointBaseType>({
156
266
  )
157
267
 
158
268
  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
159
- if (!onChange || readonly || !keyboard) return
160
269
  const key = event.key
161
- if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
162
- event.preventDefault()
163
- // y grows downwards, so ArrowUp moves the point towards 0.
164
- const axis = key === 'ArrowRight' || key === 'ArrowLeft' ? 'x' : 'y'
165
- const direction = key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1
166
- nudge(axis, direction, keyboard)
167
- }
270
+ if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key))
271
+ return
272
+ // The key picks the axis, whichever of the two inputs holds the focus: a
273
+ // point is one control to the person moving it, and the focus lands on the
274
+ // x input, so reading the axis off the input would leave the y axis with
275
+ // no keys at all.
276
+ const axis = key === 'ArrowRight' || key === 'ArrowLeft' ? 'x' : 'y'
277
+ event.preventDefault()
278
+ if (!onChange || inactive || !keyboard) return
279
+ // y grows downwards, so ArrowUp moves the point towards 0.
280
+ const direction = key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1
281
+ nudge(axis, direction, keyboard, event)
168
282
  }
169
283
 
170
- const colors: Record<string, string | undefined> = {
171
- '--color': color,
172
- }
284
+ const current = clampPoint(value, min, max)
173
285
 
174
286
  return (
175
- // The point is a drag handle rather than a control of a known kind: it has
176
- // no single value to announce, so there is no role that fits it.
177
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions
287
+ // The visual point has two values, so its semantics live on the two range
288
+ // inputs nested inside it rather than on this drag handle.
289
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
178
290
  <div
179
291
  ref={refCallback}
180
- className={clsx('tremolo-points-editor-point', className)}
181
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
182
- tabIndex={0}
183
- aria-disabled={disabled}
184
- aria-readonly={readonly}
185
- data-dragging={dragging}
186
- style={{
187
- ...colors,
188
- width: size ?? width,
189
- height: size ?? height,
190
- left: `${value.x * 100}%`,
191
- top: `${value.y * 100}%`,
192
- ...style,
193
- }}
292
+ className={className}
293
+ data-disabled={disabled ? '' : undefined}
294
+ data-readonly={readonly ? '' : undefined}
295
+ // A press lands on the point, which cannot hold focus, and the browser
296
+ // answers that by clearing the focus to the body — undoing the focus the
297
+ // drag just gave the input. Taking the focus here keeps it inside, and
298
+ // it is passed on to the input below.
299
+ tabIndex={-1}
300
+ data-dragging={dragging ? '' : undefined}
301
+ data-selected={selected ? '' : undefined}
302
+ style={
303
+ {
304
+ '--color': color,
305
+ '--width': cssLength(size ?? width),
306
+ '--height': cssLength(size ?? height),
307
+ // The mechanics of the position below: a point is placed by its
308
+ // position in the container, measured from its own centre.
309
+ position: 'absolute',
310
+ translate: 'var(--translate, -50% -50%)',
311
+ ...style,
312
+ // Where the point is: the value, not a style.
313
+ left: `${value.x * 100}%`,
314
+ top: `${value.y * 100}%`,
315
+ } as CSSProperties
316
+ }
194
317
  onPointerDown={onPointerDown}
318
+ onFocus={(event) => {
319
+ // The point itself is not the control: its semantics live on the
320
+ // inputs, so whatever reaches it is handed to the first of them.
321
+ if (!disabled && event.target === event.currentTarget) {
322
+ xInputRef.current?.focus()
323
+ }
324
+ onFocus?.(event)
325
+ }}
195
326
  onKeyDown={(event) => {
196
327
  handleKeyDown(event)
197
328
  onKeyDown?.(event)
198
329
  }}
199
330
  {...props}
200
- />
331
+ >
332
+ {(['x', 'y'] as const).map((axis) => (
333
+ <VisuallyHiddenRangeInput
334
+ key={axis}
335
+ ref={axis === 'x' ? xInputRef : yInputRef}
336
+ data-axis={axis}
337
+ value={current[axis]}
338
+ min={min?.[axis] ?? 0}
339
+ max={max?.[axis] ?? 1}
340
+ step="any"
341
+ disabled={disabled}
342
+ aria-readonly={readonly}
343
+ aria-orientation={axis === 'x' ? 'horizontal' : 'vertical'}
344
+ aria-label={perAxis(ariaLabel, axis) ?? axis}
345
+ aria-valuetext={perAxis(ariaValuetext, axis)}
346
+ onChange={(event) => {
347
+ if (readonly) {
348
+ event.currentTarget.value = String(current[axis])
349
+ return
350
+ }
351
+ const delta = event.currentTarget.valueAsNumber - value[axis]
352
+ nudgeSelection(id, {
353
+ x: axis === 'x' ? delta : 0,
354
+ y: axis === 'y' ? delta : 0,
355
+ })
356
+ }}
357
+ />
358
+ ))}
359
+ {children}
360
+ </div>
201
361
  )
202
362
  }
@@ -0,0 +1,54 @@
1
+ import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'
2
+
3
+ import { useCheckPlacement } from '../_util/Placement'
4
+
5
+ import { usePointsEditorContext } from './context'
6
+
7
+ export interface PointsEditorSelectionBoxProps {
8
+ /** Drawn inside the box, which is only there while a drag is running. */
9
+ children?: ReactNode
10
+ }
11
+
12
+ /**
13
+ * The box a drag on empty space draws, and what it covers is selected.
14
+ *
15
+ * Nothing is rendered while no drag is running, and leaving it out leaves the
16
+ * editor without one — the selection still works, it just cannot be seen.
17
+ */
18
+ export function SelectionBox({
19
+ children,
20
+ className,
21
+ style,
22
+ ...props
23
+ }: PointsEditorSelectionBoxProps &
24
+ Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorSelectionBoxProps>) {
25
+ useCheckPlacement('PointsEditor.SelectionBox', 'PointsEditor.Container')
26
+
27
+ const selectionBox = usePointsEditorContext((s) => s.selectionBox)
28
+ if (!selectionBox) return null
29
+
30
+ return (
31
+ <div
32
+ className={className}
33
+ {...props}
34
+ style={
35
+ {
36
+ // Drawn over the points while a drag runs, and taking none of the
37
+ // pointer: the drag underneath is what it is reporting on.
38
+ position: 'absolute',
39
+ zIndex: 20,
40
+ pointerEvents: 'none',
41
+ ...style,
42
+ // Where the box is and how big it is: the drag, not a style, so it
43
+ // is written after whatever the caller passed.
44
+ left: `${selectionBox.x * 100}%`,
45
+ top: `${selectionBox.y * 100}%`,
46
+ width: `${selectionBox.width * 100}%`,
47
+ height: `${selectionBox.height * 100}%`,
48
+ } as CSSProperties
49
+ }
50
+ >
51
+ {children}
52
+ </div>
53
+ )
54
+ }