@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,206 @@
1
+ import {
2
+ ComponentPropsWithoutRef,
3
+ CSSProperties,
4
+ forwardRef,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react'
10
+
11
+ import { useComposedRefs } from '../../compose-refs'
12
+
13
+ import { useNumberInputContext } from './context'
14
+
15
+ export interface NumberInputInputFieldProps {
16
+ className?: string
17
+ style?: CSSProperties
18
+ }
19
+
20
+ type Props = NumberInputInputFieldProps &
21
+ Omit<
22
+ ComponentPropsWithoutRef<'input'>,
23
+ keyof NumberInputInputFieldProps | 'type' | 'value' | 'defaultValue'
24
+ >
25
+
26
+ /** The leading number of the displayed text, whatever the format put around it. */
27
+ const NUMBER_PREFIX = /^\s*-?[\d.,]*/
28
+
29
+ const numberEnd = (text: string) =>
30
+ text.match(NUMBER_PREFIX)?.[0].length ?? text.length
31
+
32
+ /**
33
+ * The index the caret is measured against: the decimal point, or where one
34
+ * would go if the number has none.
35
+ *
36
+ * Measuring from an end instead would slide the caret across a digit whenever
37
+ * the number changed length — `9.9` to `10.0` gains a character in front, `10`
38
+ * to `9` loses one — which is exactly what stepping does.
39
+ */
40
+ const decimalAnchor = (text: string) => {
41
+ const dot = text.indexOf('.')
42
+ return dot === -1 ? numberEnd(text) : dot
43
+ }
44
+
45
+ /**
46
+ * The text field of a `NumberInput`, and the only place the value can be typed.
47
+ *
48
+ * While the user types, their own text stands rather than `format(value)`, so
49
+ * that a half-finished entry is not rewritten under the caret.
50
+ *
51
+ * How it behaves on focus, Enter and the arrow keys is set on `Root`
52
+ * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
53
+ */
54
+ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
55
+ function InputField(
56
+ { className, style, onFocus, onBlur, onKeyDown, ...props },
57
+ forwardedRef,
58
+ ) {
59
+ const {
60
+ value,
61
+ min,
62
+ max,
63
+ step,
64
+ disabled,
65
+ readonly,
66
+ keyboard,
67
+ selectOnFocus,
68
+ unformatOnFocus,
69
+ keepCaretOnStep,
70
+ blurOnEnter,
71
+ text,
72
+ editing,
73
+ outOfRange,
74
+ setDraft,
75
+ commitDraft,
76
+ nudge,
77
+ inputRef,
78
+ } = useNumberInputContext()
79
+
80
+ const [focused, setFocused] = useState(false)
81
+
82
+ // Only until the first keystroke: from then on the draft is the user's own
83
+ // text and stands on its own, formatted or not.
84
+ const shown = unformatOnFocus && focused && !editing ? String(value) : text
85
+
86
+ // Selecting has to wait for that swap. Called from the focus handler it
87
+ // would run against the formatted text still in the input and cover the
88
+ // wrong characters.
89
+ useEffect(() => {
90
+ if (!focused || selectOnFocus === 'none') return
91
+ const input = inputRef.current
92
+ if (!input) return
93
+ if (selectOnFocus === 'all') {
94
+ input.setSelectionRange(0, input.value.length)
95
+ } else {
96
+ input.setSelectionRange(
97
+ 0,
98
+ input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
99
+ )
100
+ }
101
+ // Deliberately not re-run as the text changes: that would drag the
102
+ // selection back over what the user is typing.
103
+ }, [focused, selectOnFocus, inputRef])
104
+
105
+ /**
106
+ * Where to put the caret once the stepped value has been rendered, and the
107
+ * text it was measured against.
108
+ */
109
+ const caretAfterStep = useRef<{ offset: number; from: string } | null>(null)
110
+
111
+ // A layout effect rather than a plain one: the caret is moved before the
112
+ // browser paints, so it is never seen at the end of the text first.
113
+ useLayoutEffect(() => {
114
+ const pending = caretAfterStep.current
115
+ if (!pending) return
116
+ caretAfterStep.current = null
117
+
118
+ const input = inputRef.current
119
+ // The step may have been clamped away, leaving the text as it was. The
120
+ // caret has not moved either, since the key press was prevented.
121
+ if (!input || input.value === pending.from) return
122
+
123
+ const place = decimalAnchor(input.value) + pending.offset
124
+ const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
125
+ input.setSelectionRange(caret, caret)
126
+ })
127
+
128
+ const composedRef = useComposedRefs<HTMLInputElement>(
129
+ forwardedRef,
130
+ inputRef,
131
+ )
132
+
133
+ return (
134
+ <input
135
+ ref={composedRef}
136
+ className={className}
137
+ // Not type="number": that brings native spinners and a value the browser
138
+ // parses itself, neither of which survives a unit suffix.
139
+ type="text"
140
+ inputMode="decimal"
141
+ role="spinbutton"
142
+ value={shown}
143
+ disabled={disabled}
144
+ readOnly={readonly}
145
+ aria-disabled={disabled}
146
+ aria-readonly={readonly}
147
+ data-disabled={disabled ? '' : undefined}
148
+ data-readonly={readonly ? '' : undefined}
149
+ aria-valuenow={value}
150
+ aria-valuemin={min}
151
+ aria-valuemax={max}
152
+ // The formatted text, even while the field shows the plain number: it is
153
+ // the one that says what the value means.
154
+ aria-valuetext={text}
155
+ step={step}
156
+ data-out-of-range={outOfRange ? '' : undefined}
157
+ style={style}
158
+ onChange={(event) => {
159
+ // The text is the user's own now, so a caret measured against the
160
+ // stepped one no longer means anything.
161
+ caretAfterStep.current = null
162
+ setDraft(event.currentTarget.value)
163
+ }}
164
+ onFocus={(event) => {
165
+ setFocused(true)
166
+ onFocus?.(event)
167
+ }}
168
+ onBlur={(event) => {
169
+ setFocused(false)
170
+ // Nothing was typed, so there is no draft and this returns at once —
171
+ // taking focus and leaving again never commits anything.
172
+ commitDraft()
173
+ onBlur?.(event)
174
+ }}
175
+ onKeyDown={(event) => {
176
+ const key = event.key
177
+ if (key === 'Enter') {
178
+ commitDraft()
179
+ if (blurOnEnter) event.currentTarget.blur()
180
+ } else if (
181
+ keyboard &&
182
+ !disabled &&
183
+ !readonly &&
184
+ (key === 'ArrowUp' || key === 'ArrowDown') &&
185
+ // Arrow keys pick a candidate while an IME is converting. Stepping
186
+ // the value there would fight the conversion, and moving the caret
187
+ // would break it outright.
188
+ !event.nativeEvent.isComposing
189
+ ) {
190
+ event.preventDefault()
191
+ const input = event.currentTarget
192
+ if (keepCaretOnStep && input.selectionStart !== null) {
193
+ caretAfterStep.current = {
194
+ offset: input.selectionStart - decimalAnchor(input.value),
195
+ from: input.value,
196
+ }
197
+ }
198
+ nudge(key === 'ArrowUp' ? 1 : -1, keyboard, event)
199
+ }
200
+ onKeyDown?.(event)
201
+ }}
202
+ {...props}
203
+ />
204
+ )
205
+ },
206
+ )
@@ -1,25 +1,154 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, ReactNode } from 'react'
1
+ import {
2
+ ComponentPropsWithoutRef,
3
+ CSSProperties,
4
+ forwardRef,
5
+ ReactNode,
6
+ useMemo,
7
+ useRef,
8
+ } from 'react'
3
9
 
4
- export interface StepperProps {
5
- /** Display only when hovering. */
6
- dynamic?: boolean
10
+ import {
11
+ applyDelta,
12
+ mapModifier,
13
+ selectModifier,
14
+ type InputEventOption,
15
+ } from '@tremolo-ui/functions'
16
+
17
+ import { useComposedRefs } from '../../compose-refs'
18
+ import { useDrag } from '../../hooks/useDrag'
19
+
20
+ import { StepperProvider, useNumberInputContext } from './context'
21
+
22
+ export interface NumberInputStepperProps {
23
+ className?: string
24
+ style?: CSSProperties
25
+ /** `<NumberInput.IncrementStepper />` and `<NumberInput.DecrementStepper />` go here. */
7
26
  children?: ReactNode
8
27
  }
9
28
 
10
- export function Stepper({
11
- dynamic = true,
12
- children,
13
- className,
14
- ...props
15
- }: StepperProps & Omit<ComponentPropsWithoutRef<'div'>, keyof StepperProps>) {
16
- return (
17
- <div
18
- className={clsx('tremolo-number-input-stepper', className)}
19
- data-dynamic={dynamic}
20
- {...props}
21
- >
22
- {children}
23
- </div>
24
- )
25
- }
29
+ type Props = NumberInputStepperProps &
30
+ Omit<ComponentPropsWithoutRef<'div'>, keyof NumberInputStepperProps>
31
+
32
+ /**
33
+ * The area the steppers sit in, and a drag handle in its own right: dragging it
34
+ * up and down moves the value one `step` every `drag` pixels.
35
+ *
36
+ * The drag lives here rather than on `InputField` because `createDrag` turns
37
+ * off text selection on whatever element it is attached to.
38
+ */
39
+ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
40
+ function Stepper({ className, style, children, ...props }, forwardedRef) {
41
+ const {
42
+ value,
43
+ step,
44
+ disabled,
45
+ readonly,
46
+ drag,
47
+ dragSensitivity,
48
+ pointerLock,
49
+ rawRange,
50
+ changeValue,
51
+ } = useNumberInputContext()
52
+ const inactive = disabled || readonly
53
+
54
+ /**
55
+ * The sensitivity as an amount per `drag` pixels, so that the drag goes
56
+ * through the same `applyDelta` the wheel and the arrow keys do. Carrying
57
+ * the modifier map over rather than resolving it here is what keeps `step`
58
+ * out of the pipeline for a modifier entry — naming one is a request to move
59
+ * off the grid.
60
+ */
61
+ const dragOptions = useMemo(
62
+ () =>
63
+ mapModifier(dragSensitivity, (factor): InputEventOption => [
64
+ 'raw',
65
+ step * factor,
66
+ ]),
67
+ [dragSensitivity, step],
68
+ )
69
+
70
+ /**
71
+ * Set once the drag has actually moved the value, so that the press-and-hold
72
+ * repeat of a stepper stands down and leaves the value to the drag. A ref,
73
+ * because it is read from inside the repeat rather than rendered.
74
+ */
75
+ const draggingRef = useRef(false)
76
+ /**
77
+ * Where the drag started, taken on the first move rather than on pointer
78
+ * down: the steppers act on pointer down, so by then the value may already
79
+ * have been nudged once, and the drag should carry on from there.
80
+ */
81
+ const originRef = useRef<{ y: number; value: number } | null>(null)
82
+ /**
83
+ * Which modifier the drag is currently counting at, and where the previous
84
+ * event was — a key produces no pointer event of its own, so a change is
85
+ * only seen on the next move and has to be dated back to the one before it.
86
+ */
87
+ const factorRef = useRef<number>(1)
88
+ const previousYRef = useRef(0)
89
+
90
+ const dragRefCallback = useDrag<HTMLDivElement>({
91
+ threshold: 1,
92
+ cursor: inactive ? undefined : 'ns-resize',
93
+ pointerLock: inactive ? false : pointerLock,
94
+ onDragStart: (state) => {
95
+ originRef.current = null
96
+ draggingRef.current = false
97
+ factorRef.current = selectModifier(dragSensitivity, state.event).value
98
+ },
99
+ onDrag: (_x, y, _dx, _dy, state) => {
100
+ if (inactive || drag === null) return
101
+ if (!originRef.current) {
102
+ originRef.current = { y, value }
103
+ previousYRef.current = y
104
+ return
105
+ }
106
+
107
+ // Pressing or releasing the key mid-drag must not move the value, so the
108
+ // travel so far is folded into the origin and measuring starts again
109
+ // from the previous event: that event's own distance belongs to the new
110
+ // sensitivity.
111
+ const factor = selectModifier(dragSensitivity, state.event).value
112
+ if (factor !== factorRef.current) {
113
+ originRef.current = { y: previousYRef.current, value }
114
+ factorRef.current = factor
115
+ }
116
+ previousYRef.current = y
117
+
118
+ // Dragging up raises the value, as on a knob.
119
+ const steps = Math.round(-(y - originRef.current.y) / drag)
120
+ if (steps === 0) return
121
+ draggingRef.current = true
122
+ // The same pipeline the wheel and the arrow keys use. Counting from where
123
+ // the drag started keeps it from accumulating a rounding error.
124
+ changeValue(
125
+ applyDelta(
126
+ originRef.current.value,
127
+ steps,
128
+ dragOptions,
129
+ rawRange,
130
+ state.event,
131
+ ),
132
+ )
133
+ },
134
+ onDragEnd: () => {
135
+ draggingRef.current = false
136
+ },
137
+ })
138
+
139
+ const composedRef = useComposedRefs<HTMLDivElement>(
140
+ forwardedRef,
141
+ drag !== null && !inactive ? dragRefCallback : undefined,
142
+ )
143
+
144
+ const context = useMemo(() => ({ draggingRef }), [])
145
+
146
+ return (
147
+ <StepperProvider value={context}>
148
+ <div ref={composedRef} className={className} style={style} {...props}>
149
+ {children}
150
+ </div>
151
+ </StepperProvider>
152
+ )
153
+ },
154
+ )
@@ -0,0 +1,95 @@
1
+ import { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react'
2
+
3
+ import { useLongPress } from '../../hooks/useLongPress'
4
+
5
+ import { useNumberInputContext, useStepperContext } from './context'
6
+
7
+ export interface StepperButtonProps {
8
+ className?: string
9
+ style?: CSSProperties
10
+ /** Replaces the default arrow. Size it with the `--stepper-icon-size` variable. */
11
+ children?: ReactNode
12
+ }
13
+
14
+ type Props = StepperButtonProps &
15
+ Omit<ComponentPropsWithoutRef<'div'>, keyof StepperButtonProps>
16
+
17
+ /**
18
+ * Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
19
+ * in which way they move the value and which arrow they draw.
20
+ */
21
+ export function StepperButton({
22
+ direction,
23
+ variant,
24
+ icon,
25
+ className,
26
+ style,
27
+ children,
28
+ onPointerDown,
29
+ ...props
30
+ }: Props & {
31
+ direction: 1 | -1
32
+ variant: 'increment' | 'decrement'
33
+ icon: ReactNode
34
+ }) {
35
+ const { step, disabled, readonly, atMin, atMax, nudge } =
36
+ useNumberInputContext()
37
+ const stepper = useStepperContext()
38
+
39
+ // Not memoized: the callback reads `draggingRef.current`, which the compiler
40
+ // cannot line up with a manual dependency list. `useLongPress` only ever
41
+ // calls it through a ref, so a fresh identity per render costs nothing.
42
+ const press = useLongPress(() => {
43
+ // Once the pointer has actually travelled, the drag on `Stepper` owns the
44
+ // value; repeating on top of it would move it twice.
45
+ if (stepper?.draggingRef.current) return
46
+ nudge(direction, ['raw', step])
47
+ })
48
+
49
+ return (
50
+ <div
51
+ className={className}
52
+ role="button"
53
+ tabIndex={-1}
54
+ // A bare arrow has no accessible name of its own. Overridable, since a
55
+ // caller may need it in their own language.
56
+ aria-label={direction > 0 ? 'Increment' : 'Decrement'}
57
+ aria-disabled={disabled || (direction > 0 ? atMax : atMin)}
58
+ // `role="button"` does not take aria-readonly, so the state reaches the
59
+ // styles through the data attribute alone.
60
+ data-disabled={
61
+ disabled || (direction > 0 ? atMax : atMin) ? '' : undefined
62
+ }
63
+ data-readonly={readonly ? '' : undefined}
64
+ style={style}
65
+ onPointerDown={(event) => {
66
+ if (!disabled && !readonly) press(event)
67
+ onPointerDown?.(event)
68
+ }}
69
+ {...props}
70
+ >
71
+ {children ?? icon}
72
+ </div>
73
+ )
74
+ }
75
+
76
+ export function StepperArrow({ up }: { up: boolean }) {
77
+ return (
78
+ <svg
79
+ // The arrow the component draws when no children are given, so it sizes
80
+ // itself rather than asking a stylesheet to.
81
+ style={{
82
+ width: 'var(--stepper-icon-size)',
83
+ height: 'var(--stepper-icon-size)',
84
+ }}
85
+ viewBox="0 0 24 24"
86
+ fill="none"
87
+ stroke="currentColor"
88
+ strokeWidth="2"
89
+ strokeLinecap="round"
90
+ strokeLinejoin="round"
91
+ >
92
+ <polyline points={up ? '18 15 12 9 6 15' : '6 9 12 15 18 9'} />
93
+ </svg>
94
+ )
95
+ }