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