@tremolo-ui/react 0.4.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 (90) 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 +2272 -1650
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1461 -475
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1461 -475
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2258 -1648
  16. package/dist/index.js.map +1 -1
  17. package/package.json +32 -31
  18. package/src/components/AnimationCanvas/index.tsx +162 -194
  19. package/src/components/Knob/ActiveLine.tsx +9 -8
  20. package/src/components/Knob/InactiveLine.tsx +11 -14
  21. package/src/components/Knob/SVGRoot.tsx +10 -17
  22. package/src/components/Knob/Thumb.tsx +33 -11
  23. package/src/components/Knob/context.tsx +73 -89
  24. package/src/components/Knob/index.tsx +227 -105
  25. package/src/components/NumberInput/DecrementStepper.tsx +21 -50
  26. package/src/components/NumberInput/IncrementStepper.tsx +22 -50
  27. package/src/components/NumberInput/InputField.tsx +206 -0
  28. package/src/components/NumberInput/Stepper.tsx +150 -21
  29. package/src/components/NumberInput/StepperButton.tsx +95 -0
  30. package/src/components/NumberInput/context.tsx +104 -149
  31. package/src/components/NumberInput/index.tsx +461 -110
  32. package/src/components/Piano/index.tsx +413 -263
  33. package/src/components/Piano/keyboard-shortcuts.ts +61 -0
  34. package/src/components/PointsEditor/Background.tsx +19 -4
  35. package/src/components/PointsEditor/Container.tsx +92 -22
  36. package/src/components/PointsEditor/Point.tsx +293 -53
  37. package/src/components/PointsEditor/SelectionBox.tsx +54 -0
  38. package/src/components/PointsEditor/context.tsx +97 -75
  39. package/src/components/PointsEditor/index.tsx +497 -62
  40. package/src/components/Slider/Marks.tsx +84 -0
  41. package/src/components/Slider/MarksOption.tsx +115 -0
  42. package/src/components/Slider/Thumb.tsx +99 -54
  43. package/src/components/Slider/Track.tsx +73 -62
  44. package/src/components/Slider/context.tsx +40 -53
  45. package/src/components/Slider/index.tsx +237 -190
  46. package/src/components/Slider/type.ts +27 -9
  47. package/src/components/XYPad/Area.tsx +52 -33
  48. package/src/components/XYPad/Thumb.tsx +121 -60
  49. package/src/components/XYPad/context.tsx +49 -0
  50. package/src/components/XYPad/index.tsx +338 -246
  51. package/src/components/_util/Placement.tsx +68 -0
  52. package/src/components/_util/VisuallyHiddenRangeInput.tsx +24 -0
  53. package/src/components/_util/css-length.ts +13 -0
  54. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  55. package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +3 -1
  56. package/src/hooks/_internal/useCheckSteps.ts +170 -0
  57. package/src/hooks/useAnimationFrame.ts +17 -7
  58. package/src/hooks/useDrag.ts +39 -9
  59. package/src/hooks/useDragValue.ts +194 -0
  60. package/src/hooks/useEventListener.ts +11 -7
  61. package/src/hooks/useLongPress.ts +42 -8
  62. package/src/hooks/useMIDIAccess.ts +82 -11
  63. package/src/hooks/useMIDIInput.ts +45 -13
  64. package/src/hooks/useMIDIMessage.ts +23 -5
  65. package/src/hooks/useWheel.ts +29 -9
  66. package/src/index.ts +50 -27
  67. package/src/input-event.ts +32 -0
  68. package/dist/index.css +0 -536
  69. package/src/Globals.d.ts +0 -3
  70. package/src/components/AnimationCanvas/canvas.ts +0 -46
  71. package/src/components/Knob/index.css +0 -77
  72. package/src/components/NumberInput/InternalInput.tsx +0 -216
  73. package/src/components/NumberInput/index.css +0 -185
  74. package/src/components/NumberInput/type.ts +0 -58
  75. package/src/components/Piano/KeyLabel.tsx +0 -51
  76. package/src/components/Piano/context.tsx +0 -73
  77. package/src/components/Piano/index.css +0 -90
  78. package/src/components/Piano/key.tsx +0 -205
  79. package/src/components/Piano/keyboardShortcuts.ts +0 -31
  80. package/src/components/PointsEditor/index.css +0 -30
  81. package/src/components/Slider/Scale.tsx +0 -52
  82. package/src/components/Slider/ScaleOption.tsx +0 -103
  83. package/src/components/Slider/index.css +0 -112
  84. package/src/components/XYPad/index.css +0 -48
  85. package/src/components/_util/index.ts +0 -28
  86. package/src/components/_util/type.ts +0 -1
  87. package/src/hooks/useDragWithElement.ts +0 -100
  88. package/src/hooks/usePianoDrag.ts +0 -75
  89. package/src/hooks/useRefCallbackEvent.ts +0 -34
  90. package/src/styles/global.css +0 -19
@@ -0,0 +1,61 @@
1
+ export type KeyboardShortcuts = {
2
+ /**
3
+ * Keys laid out from `noteRange.first`, one entry per semitone.
4
+ *
5
+ * An empty string leaves that note without a shortcut: `KeyboardEvent.key` is
6
+ * never empty, so the entry can never match. Use it to skip the black keys
7
+ * (see {@link SHORTCUTS.HOME_ROW_NATURAL}) and keep the remaining entries
8
+ * lined up with the notes.
9
+ */
10
+ keys: string[]
11
+ }
12
+
13
+ /**
14
+ * Ready-made keyboard layouts. Both assume `noteRange.first` is a C.
15
+ */
16
+ export const SHORTCUTS = {
17
+ /** Every semitone from C, over the two rows of a QWERTY keyboard. */
18
+ HOME_ROW: {
19
+ keys: [
20
+ 'a',
21
+ 'w',
22
+ 's',
23
+ 'e',
24
+ 'd',
25
+ 'f',
26
+ 't',
27
+ 'g',
28
+ 'y',
29
+ 'h',
30
+ 'u',
31
+ 'j',
32
+ 'k',
33
+ 'o',
34
+ 'l',
35
+ 'p',
36
+ ';',
37
+ ],
38
+ },
39
+ /** The white keys only, on the home row. Black keys have no shortcut. */
40
+ HOME_ROW_NATURAL: {
41
+ keys: [
42
+ 'a',
43
+ '',
44
+ 's',
45
+ '',
46
+ 'd',
47
+ 'f',
48
+ '',
49
+ 'g',
50
+ '',
51
+ 'h',
52
+ '',
53
+ 'j',
54
+ 'k',
55
+ '',
56
+ 'l',
57
+ '',
58
+ ';',
59
+ ],
60
+ },
61
+ }
@@ -1,14 +1,29 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef } from 'react'
1
+ import { ComponentPropsWithoutRef, ReactNode } from 'react'
2
+
3
+ export interface PointsEditorBackgroundProps {
4
+ /** Whatever the points are placed over: a graph, a canvas, an image. */
5
+ children?: ReactNode
6
+ }
3
7
 
4
8
  export function Background({
5
9
  className,
6
10
  children,
11
+ style,
7
12
  ...props
8
- }: ComponentPropsWithoutRef<'div'>) {
13
+ }: PointsEditorBackgroundProps &
14
+ Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorBackgroundProps>) {
9
15
  return (
10
16
  <div
11
- className={clsx('tremolo-points-editor-background', className)}
17
+ className={className}
18
+ style={{
19
+ // Behind the points, filling the editor: what it is for, rather than
20
+ // a look. The order of the layers holds whichever way they are
21
+ // written, so the z-index is part of the same mechanics.
22
+ position: 'absolute',
23
+ inset: 0,
24
+ zIndex: 0,
25
+ ...style,
26
+ }}
12
27
  {...props}
13
28
  >
14
29
  {children}
@@ -1,26 +1,96 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, useRef } from 'react'
1
+ import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
2
+
3
+ import { useComposedRefs } from '../../compose-refs'
4
+ import { useDragValue } from '../../hooks/useDragValue'
5
+ import { useWheel } from '../../hooks/useWheel'
6
+ import { Placement } from '../_util/Placement'
3
7
 
4
8
  import { usePointsEditorContext } from './context'
9
+ import { AXIS } from './Point'
5
10
 
6
- export function Container({
7
- className,
8
- children,
9
- ...props
10
- }: ComponentPropsWithoutRef<'div'>) {
11
- const containerElementRef = useRef<HTMLDivElement>(null)
12
- const setContainerElementRef = usePointsEditorContext(
13
- (s) => s.setContainerElementRef,
14
- )
15
- setContainerElementRef(containerElementRef)
16
-
17
- return (
18
- <div
19
- ref={containerElementRef}
20
- className={clsx('tremolo-points-editor-container', className)}
21
- {...props}
22
- >
23
- {children}
24
- </div>
25
- )
11
+ export interface PointsEditorContainerProps {
12
+ /** `<PointsEditor.Point />` goes here. */
13
+ children?: ReactNode
26
14
  }
15
+
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,12 +1,30 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef } 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 { clamp } 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
 
6
- import { useDragWithElement } from '../../hooks/useDragWithElement'
7
- import { addUserSelectNone, removeUserSelectNone } from '../_util'
21
+ import { useComposedRefs } from '../../compose-refs'
22
+ import { useDragValue } from '../../hooks/useDragValue'
23
+ import { cssLength } from '../_util/css-length'
24
+ import { useCheckPlacement } from '../_util/Placement'
25
+ import { VisuallyHiddenRangeInput } from '../_util/VisuallyHiddenRangeInput'
8
26
 
9
- import { usePointsEditorContext } from './context'
27
+ import { type PointRegistration, usePointsEditorContext } from './context'
10
28
 
11
29
  export type PointBaseType = { x: number; y: number }
12
30
 
@@ -21,35 +39,97 @@ export function clampPoint(
21
39
  return { x: newX, y: newY }
22
40
  }
23
41
 
24
- 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
+ */
25
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. */
26
57
  min?: Partial<PointBaseType>
58
+ /** The highest position the point can take, per axis. An axis left out is 1. */
27
59
  max?: Partial<PointBaseType>
28
60
 
61
+ /** Width and height at once. Sets both `--width` and `--height`. */
29
62
  size?: number | string
63
+ /** Sets `--width`; the size the theme gives it stands when omitted. */
30
64
  width?: number | string
65
+ /** Sets `--height`; the size the theme gives it stands when omitted. */
31
66
  height?: number | string
67
+ /** Sets `--color`, for the theme to colour the point with. */
32
68
  color?: string
33
69
 
70
+ /** Overrides the `disabled` of `PointsEditor.Root`. */
34
71
  disabled?: boolean
72
+ /** Overrides the `readonly` of `PointsEditor.Root`. */
35
73
  readonly?: boolean
36
74
 
75
+ /** Overrides the `wheel` of `PointsEditor.Root`. */
76
+ wheel?: ModifierValue<InputEventOption> | null
77
+ /** Overrides the `keyboard` of `PointsEditor.Root`. */
78
+ keyboard?: ModifierValue<InputEventOption> | null
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
+ */
37
92
  onChange?: (value: PointBaseType) => void
93
+ /** Called when a drag on this point starts, with where the point is. */
38
94
  onDragStart?: (value: PointBaseType) => void
95
+ /** Called when that drag ends, with where the point is. */
39
96
  onDragEnd?: (value: PointBaseType) => void
40
97
  }
41
98
 
99
+ /**
100
+ * A point is placed by its position within the container, so its value is a
101
+ * position: 0..1 on each axis, with y growing downwards.
102
+ */
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
+ }
115
+
42
116
  export function Point<T extends PointBaseType>({
43
117
  value,
118
+ children,
119
+ id: idProp,
44
120
  min,
45
121
  max,
46
122
  size,
47
- width = 16,
48
- height = 16,
123
+ width,
124
+ height,
49
125
  color,
50
126
 
51
- disabled,
52
- readonly,
127
+ disabled: _disabled,
128
+ readonly: _readonly,
129
+ wheel: _wheel,
130
+ keyboard: _keyboard,
131
+ 'aria-label': ariaLabel,
132
+ 'aria-valuetext': ariaValuetext,
53
133
 
54
134
  onChange,
55
135
  onDragStart,
@@ -58,65 +138,225 @@ export function Point<T extends PointBaseType>({
58
138
  className,
59
139
  style,
60
140
  onPointerDown,
141
+ onKeyDown,
142
+ onFocus,
61
143
  ...props
62
- }: PointProps<T> & Omit<ComponentPropsWithoutRef<'div'>, keyof PointProps<T>>) {
63
- const containerElementRef = usePointsEditorContext(
64
- (s) => s.containerElementRef,
65
- )
66
- const externalStyles = usePointsEditorContext((s) => s.externalStyles)
67
- const __disabled = usePointsEditorContext((s) => s.disabled)
68
- const __readonly = usePointsEditorContext((s) => s.readonly)
144
+ }: PointsEditorPointProps<T> &
145
+ Omit<ComponentPropsWithoutRef<'div'>, keyof PointsEditorPointProps<T>>) {
146
+ const {
147
+ containerRef,
148
+ externalStyles,
149
+ disabled: rootDisabled,
150
+ readonly: rootReadonly,
151
+ wheel: rootWheel,
152
+ keyboard: rootKeyboard,
153
+ dragSensitivity,
154
+ selection,
155
+ registerPoint,
156
+ beginPointDrag,
157
+ movePointDrag,
158
+ nudgeSelection,
159
+ } = usePointsEditorContext()
69
160
 
70
- // console.log(value.x, value.y)
161
+ const generatedId = useId()
162
+ const id = idProp ?? generatedId
163
+ const selected = selection.includes(id)
71
164
 
72
- // Piano: 値が更新されることで、イベントハンドラがリセットされる
165
+ const disabled = _disabled ?? rootDisabled
166
+ const readonly = _readonly ?? rootReadonly
167
+ const inactive = disabled || readonly
168
+ // `null` means "no event" and has to survive the fallback, so `??` is not
169
+ // enough: only an omitted prop inherits from the root.
170
+ const wheel = _wheel === undefined ? rootWheel : _wheel
171
+ const keyboard = _keyboard === undefined ? rootKeyboard : _keyboard
73
172
 
74
- const { refCallback: dragRefCallback, dragging } =
75
- useDragWithElement<HTMLDivElement>({
76
- baseElementRef: containerElementRef,
77
- cursor: readonly ? undefined : externalStyles.cursor,
78
- onDrag: (x, y) => {
79
- if (readonly) return
173
+ useCheckPlacement('PointsEditor.Point', 'PointsEditor.Container')
174
+
175
+ // Compared against the focus below, so the point needs its own element.
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])
80
205
 
81
- onChange?.(clampPoint({ x, y }, min, max))
206
+ /** Where the pointer was when the drag started, to measure the move from. */
207
+ const pointerOrigin = useRef<PointBaseType | null>(null)
208
+
209
+ // The value is the position itself: no scaling, and no rounding to a step.
210
+ const { refCallback: dragRefCallback, dragging } =
211
+ useDragValue<HTMLDivElement>({
212
+ axis: AXIS,
213
+ baseElementRef: containerRef,
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.
221
+ onChange: ([x, y]) => {
222
+ const origin = pointerOrigin.current
223
+ if (!origin) return
224
+ movePointDrag({ x: x - origin.x, y: y - origin.y })
82
225
  },
83
- onDragStart: (x, y) => {
84
- if (readonly) return
85
- 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()
86
233
 
87
- onDragStart?.(clampPoint({ x, y }, min, max))
234
+ if (inactive) return
235
+ onDragStart?.(clampPoint(value, min, max))
88
236
  },
89
- onDragEnd: (x, y) => {
90
- if (readonly) return
91
- if (externalStyles.userSelectNone) removeUserSelectNone()
237
+ onDragEnd: () => {
238
+ pointerOrigin.current = null
92
239
 
93
- onDragEnd?.(clampPoint({ x, y }, min, max))
240
+ if (inactive) return
241
+ onDragEnd?.(clampPoint(value, min, max))
94
242
  },
95
243
  })
96
244
 
97
- const colors: Record<string, string | undefined> = {
98
- '--color': color,
245
+ const nudge = useCallback(
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
+ })
259
+ },
260
+ [value, id, nudgeSelection],
261
+ )
262
+
263
+ const refCallback = useComposedRefs<HTMLDivElement>(
264
+ dragRefCallback,
265
+ setElement,
266
+ )
267
+
268
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
269
+ const key = event.key
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)
99
282
  }
100
283
 
284
+ const current = clampPoint(value, min, max)
285
+
101
286
  return (
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
102
290
  <div
103
- ref={dragRefCallback}
104
- className={clsx('tremolo-points-editor-point', className)}
105
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
106
- tabIndex={0}
107
- aria-disabled={disabled != undefined ? disabled : __disabled}
108
- aria-readonly={readonly != undefined ? readonly : __readonly}
109
- data-dragging={dragging}
110
- style={{
111
- ...colors,
112
- width: size ?? width,
113
- height: size ?? height,
114
- left: `${value.x * 100}%`,
115
- top: `${value.y * 100}%`,
116
- ...style,
117
- }}
291
+ ref={refCallback}
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
+ }
118
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
+ }}
326
+ onKeyDown={(event) => {
327
+ handleKeyDown(event)
328
+ onKeyDown?.(event)
329
+ }}
119
330
  {...props}
120
- />
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>
121
361
  )
122
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
+ }