@tremolo-ui/react 0.4.0 → 0.5.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 (62) hide show
  1. package/dist/index.cjs +1125 -1437
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +561 -243
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +561 -243
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1125 -1438
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -10
  11. package/src/components/AnimationCanvas/index.tsx +84 -155
  12. package/src/components/Knob/ActiveLine.tsx +6 -6
  13. package/src/components/Knob/InactiveLine.tsx +9 -11
  14. package/src/components/Knob/SVGRoot.tsx +9 -18
  15. package/src/components/Knob/context.tsx +57 -89
  16. package/src/components/Knob/index.css +6 -2
  17. package/src/components/Knob/index.tsx +90 -61
  18. package/src/components/NumberInput/DecrementStepper.tsx +17 -50
  19. package/src/components/NumberInput/IncrementStepper.tsx +17 -50
  20. package/src/components/NumberInput/InputField.tsx +123 -0
  21. package/src/components/NumberInput/Stepper.tsx +90 -12
  22. package/src/components/NumberInput/context.tsx +74 -146
  23. package/src/components/NumberInput/index.css +38 -102
  24. package/src/components/NumberInput/index.tsx +252 -98
  25. package/src/components/NumberInput/stepperButton.tsx +90 -0
  26. package/src/components/Piano/index.tsx +272 -288
  27. package/src/components/Piano/keyboardShortcuts.ts +34 -4
  28. package/src/components/PointsEditor/Background.tsx +8 -2
  29. package/src/components/PointsEditor/Container.tsx +19 -9
  30. package/src/components/PointsEditor/Point.tsx +104 -24
  31. package/src/components/PointsEditor/context.tsx +28 -74
  32. package/src/components/PointsEditor/index.css +5 -0
  33. package/src/components/PointsEditor/index.tsx +77 -43
  34. package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
  35. package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
  36. package/src/components/Slider/Thumb.tsx +55 -68
  37. package/src/components/Slider/Track.tsx +15 -15
  38. package/src/components/Slider/context.tsx +36 -51
  39. package/src/components/Slider/index.css +18 -5
  40. package/src/components/Slider/index.tsx +127 -146
  41. package/src/components/Slider/type.ts +4 -4
  42. package/src/components/XYPad/Area.tsx +16 -7
  43. package/src/components/XYPad/Thumb.tsx +62 -74
  44. package/src/components/XYPad/context.tsx +46 -0
  45. package/src/components/XYPad/index.css +12 -0
  46. package/src/components/XYPad/index.tsx +190 -230
  47. package/src/hooks/useDrag.ts +1 -1
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +29 -9
  51. package/src/index.ts +15 -9
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/NumberInput/InternalInput.tsx +0 -216
  54. package/src/components/NumberInput/type.ts +0 -58
  55. package/src/components/Piano/KeyLabel.tsx +0 -51
  56. package/src/components/Piano/context.tsx +0 -73
  57. package/src/components/Piano/key.tsx +0 -205
  58. package/src/components/_util/type.ts +0 -1
  59. package/src/hooks/useDragWithElement.ts +0 -100
  60. package/src/hooks/usePianoDrag.ts +0 -75
  61. package/src/hooks/useRefCallbackEvent.ts +0 -34
  62. /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
@@ -2,26 +2,29 @@ import clsx from 'clsx'
2
2
  import {
3
3
  ComponentPropsWithoutRef,
4
4
  forwardRef,
5
+ ReactNode,
5
6
  useCallback,
6
7
  useImperativeHandle,
8
+ useMemo,
7
9
  useRef,
8
- useState,
9
10
  } from 'react'
10
11
 
12
+ import type { AxisOptions, XY } from '@tremolo-ui/dom'
11
13
  import {
12
- clamp,
14
+ applyDelta,
13
15
  InputEventOption,
14
- normalizeValue,
15
- rawValue,
16
- stepValue,
16
+ linearScale,
17
+ type Scale,
18
+ type ValueRange,
17
19
  } from '@tremolo-ui/functions'
18
20
 
19
- import { useDrag } from '../../hooks/useDrag'
21
+ import { useDragValue } from '../../hooks/useDragValue'
20
22
  import { useWheel } from '../../hooks/useWheel'
21
23
  import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
24
+ import { useComposedRefs } from '../_util/composeRefs'
22
25
 
23
26
  import { ActiveLine } from './ActiveLine'
24
- import { KnobProvider } from './context'
27
+ import { calcAngles, KnobProvider } from './context'
25
28
  import { InactiveLine } from './InactiveLine'
26
29
  import { SVGRoot } from './SVGRoot'
27
30
  import { Thumb } from './Thumb'
@@ -39,7 +42,16 @@ export interface KnobProps {
39
42
 
40
43
  // optional
41
44
  step?: number
42
- skew?: number // | SkewFunction // TODO
45
+ /**
46
+ * How the value is distributed across the travel.
47
+ *
48
+ * Pick one of the scales from `@tremolo-ui/functions`: `linearScale`,
49
+ * `exponentialScale`, `curveScale(n)`, `symmetricSkewScale(n)`, or
50
+ * `skewScale(n)` for a value that has to match a JUCE parameter.
51
+ *
52
+ * @default linearScale
53
+ */
54
+ scale?: Scale
43
55
  /**
44
56
  * value set when double-clicking
45
57
  * restriction: enableDoubleClickDefault = true
@@ -55,6 +67,10 @@ export interface KnobProps {
55
67
  startValue?: number
56
68
 
57
69
  /** width and height */
70
+ /**
71
+ * Width and height of the knob.
72
+ * Defaults to the `--knob-size` CSS variable (50px).
73
+ */
58
74
  size?: number | string
59
75
 
60
76
  /**
@@ -84,6 +100,21 @@ export interface KnobProps {
84
100
  angleRange?: number
85
101
 
86
102
  onChange?: (value: number) => void
103
+
104
+ /**
105
+ * The knob renders exactly what you compose here; there is no default
106
+ * markup to fall back to.
107
+ *
108
+ * @example
109
+ * <Knob.Root value={value} min={0} max={100} onChange={setValue}>
110
+ * <Knob.SVGRoot>
111
+ * <Knob.InactiveLine />
112
+ * <Knob.ActiveLine />
113
+ * <Knob.Thumb />
114
+ * </Knob.SVGRoot>
115
+ * </Knob.Root>
116
+ */
117
+ children: ReactNode
87
118
  }
88
119
 
89
120
  export interface KnobMethods {
@@ -93,14 +124,20 @@ export interface KnobMethods {
93
124
 
94
125
  type Props = KnobProps & Omit<ComponentPropsWithoutRef<'div'>, keyof KnobProps>
95
126
 
96
- const Root = forwardRef<KnobMethods, Props>(
127
+ /**
128
+ * The wheel only acts while the focus is inside, so that scrolling a page past
129
+ * the control does not change its value.
130
+ */
131
+ const WHEEL_OPTIONS = { requireFocus: true }
132
+
133
+ export const Root = forwardRef<KnobMethods, Props>(
97
134
  (
98
135
  {
99
136
  value,
100
137
  min,
101
138
  max,
102
139
  step = 1,
103
- skew = 1,
140
+ scale = linearScale,
104
141
  defaultValue = min,
105
142
  startValue = min,
106
143
  size,
@@ -122,35 +159,14 @@ const Root = forwardRef<KnobMethods, Props>(
122
159
  }: Props,
123
160
  forwardedRef,
124
161
  ) => {
125
- const [dragging, setDragging] = useState(false)
126
- const valueRef = useRef(0)
127
162
  const elmRef = useRef<HTMLElement | SVGElement>(null)
128
163
 
129
164
  const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
130
165
 
131
166
  // --- internal functions ---
132
- const onDrag = useCallback(
133
- (_x: number, y: number) => {
134
- if (!onChange || readonly) return
135
- const v = rawValue(valueRef.current - y / 100, min, max, skew)
136
- const clamped = clamp(stepValue(v, step), min, max)
137
- onChange(clamped)
138
- },
139
- [max, min, onChange, readonly, skew, step],
140
- )
141
-
142
- const updateValueByEvent = useCallback(
143
- (eventType: InputEventOption[0], x: number) => {
144
- let newValue
145
- if (eventType == 'normalized') {
146
- const n = normalizeValue(value, min, max, skew)
147
- newValue = rawValue(n + x, min, max, skew)
148
- } else {
149
- newValue = value + x
150
- }
151
- return clamp(stepValue(newValue, step), min, max)
152
- },
153
- [max, min, skew, step, value],
167
+ const range: ValueRange = useMemo(
168
+ () => ({ min, max, step, scale }),
169
+ [min, max, step, scale],
154
170
  )
155
171
 
156
172
  const handleKeyDown = useCallback(
@@ -159,25 +175,38 @@ const Root = forwardRef<KnobMethods, Props>(
159
175
  const key = event.key
160
176
  if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
161
177
  event.preventDefault()
162
- const x =
163
- key == 'ArrowRight' || key == 'ArrowUp' ? keyboard[1] : -keyboard[1]
164
- onChange(updateValueByEvent(keyboard[0], x))
178
+ const direction = key == 'ArrowRight' || key == 'ArrowUp' ? 1 : -1
179
+ onChange(applyDelta(value, direction, keyboard, range))
165
180
  }
166
181
  },
167
- [keyboard, onChange, readonly, updateValueByEvent],
182
+ [keyboard, onChange, readonly, value, range],
168
183
  )
169
184
 
170
185
  // --- hooks ---
171
- const dragRefCallback = useDrag<HTMLElement | SVGElement>({
186
+ // The knob has no travel of its own: the value moves away from where it
187
+ // stood when the drag started, 100px of movement spanning the whole range.
188
+ // Only the vertical axis carries a value, reversed so that dragging up
189
+ // raises it.
190
+ const axis = useMemo(
191
+ (): XY<AxisOptions> => [range, { ...range, reverse: true }],
192
+ [range],
193
+ )
194
+
195
+ const { refCallback: dragRefCallback, dragging } = useDragValue<
196
+ HTMLElement | SVGElement
197
+ >({
198
+ axis,
199
+ getValue: () => [value, value],
200
+ threshold: 1,
172
201
  cursor: readonly ? undefined : externalStyles.cursor,
173
- onDrag: onDrag,
202
+ onChange: (v) => {
203
+ if (readonly) return
204
+ onChange?.(v[1])
205
+ },
174
206
  onDragStart: () => {
175
- setDragging(true)
176
- valueRef.current = normalizeValue(value, min, max, skew)
177
207
  if (externalStyles.userSelectNone) addUserSelectNone()
178
208
  },
179
209
  onDragEnd: () => {
180
- setDragging(false)
181
210
  if (externalStyles.userSelectNone) removeUserSelectNone()
182
211
  },
183
212
  })
@@ -186,9 +215,21 @@ const Root = forwardRef<KnobMethods, Props>(
186
215
  if (!wheel || readonly) return
187
216
  event.preventDefault()
188
217
  if (!onChange || event.deltaY == 0) return
189
- const x = Math.sign(event.deltaY) * -wheel[1]
190
- onChange(updateValueByEvent(wheel[0], x))
191
- })
218
+ onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range))
219
+ }, WHEEL_OPTIONS)
220
+
221
+ // Composed once, so React attaches the refs a single time instead of
222
+ // detaching and re-attaching on every render.
223
+ const rootRefCallback = useComposedRefs<HTMLElement | SVGElement>(
224
+ elmRef,
225
+ dragRefCallback,
226
+ wheelRefCallback,
227
+ )
228
+
229
+ const context = useMemo(() => {
230
+ const config = { value, min, max, step, scale, startValue, angleRange }
231
+ return { ...config, ...calcAngles(config) }
232
+ }, [value, min, max, step, scale, startValue, angleRange])
192
233
 
193
234
  useImperativeHandle(forwardedRef, () => {
194
235
  return {
@@ -202,21 +243,9 @@ const Root = forwardRef<KnobMethods, Props>(
202
243
  }, [])
203
244
 
204
245
  return (
205
- <KnobProvider
206
- value={value}
207
- min={min}
208
- max={max}
209
- step={step}
210
- skew={skew}
211
- startValue={startValue}
212
- angleRange={angleRange}
213
- >
246
+ <KnobProvider value={context}>
214
247
  <div
215
- ref={(elm) => {
216
- elmRef.current = elm
217
- wheelRefCallback(elm)
218
- dragRefCallback(elm)
219
- }}
248
+ ref={rootRefCallback}
220
249
  className={clsx('tremolo-knob', className)}
221
250
  tabIndex={0}
222
251
  role="slider"
@@ -244,7 +273,7 @@ const Root = forwardRef<KnobMethods, Props>(
244
273
  }}
245
274
  {...props}
246
275
  >
247
- {children || <SVGRoot />}
276
+ {children}
248
277
  </div>
249
278
  </KnobProvider>
250
279
  )
@@ -1,57 +1,24 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, ReactNode } from 'react'
1
+ import { ComponentPropsWithoutRef } from 'react'
3
2
 
4
- import { useLongPress } from '../../hooks/useLongPress'
3
+ import {
4
+ StepperArrow,
5
+ StepperButton,
6
+ StepperButtonProps,
7
+ } from './stepperButton'
5
8
 
6
- import { useNumberInputContext } from './context'
7
-
8
- export interface DecrementStepperProps {
9
- size?: number
10
- children?: ReactNode
11
- }
12
-
13
- export function DecrementStepper({
14
- size = 12,
15
- children,
16
- className,
17
- ...props
18
- }: DecrementStepperProps &
19
- Omit<ComponentPropsWithoutRef<'div'>, keyof DecrementStepperProps>) {
20
- const min = useNumberInputContext((s) => s.min)
21
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber)
22
- const readonly = useNumberInputContext((s) => s.readonly)
23
- const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange)
24
- const decrement = useNumberInputContext((s) => s.decrement)
25
-
26
- const press = useLongPress(decrement)
9
+ export type DecrementStepperProps = StepperButtonProps
27
10
 
11
+ /** Lowers the value by one `step`, repeating while held. */
12
+ export function DecrementStepper(
13
+ props: DecrementStepperProps &
14
+ Omit<ComponentPropsWithoutRef<'div'>, keyof DecrementStepperProps>,
15
+ ) {
28
16
  return (
29
- // eslint-disable-next-line jsx-a11y/role-supports-aria-props
30
- <div
31
- className={clsx('tremolo-number-input-decrement-stepper', className)}
32
- role="button"
33
- tabIndex={-1}
34
- aria-disabled={
35
- keepWithinRange && valueAsNumber <= (min ?? Number.MIN_SAFE_INTEGER)
36
- }
37
- aria-readonly={readonly}
38
- onPointerDown={press}
17
+ <StepperButton
18
+ direction={-1}
19
+ variant="decrement"
20
+ icon={<StepperArrow up={false} />}
39
21
  {...props}
40
- >
41
- {children || (
42
- <svg
43
- width={size}
44
- height={size}
45
- viewBox="0 0 24 24"
46
- fill="none"
47
- stroke="currentColor"
48
- strokeWidth="2"
49
- strokeLinecap="round"
50
- strokeLinejoin="round"
51
- >
52
- <polyline points="6 9 12 15 18 9" />
53
- </svg>
54
- )}
55
- </div>
22
+ />
56
23
  )
57
24
  }
@@ -1,57 +1,24 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, ReactNode } from 'react'
1
+ import { ComponentPropsWithoutRef } from 'react'
3
2
 
4
- import { useLongPress } from '../../hooks/useLongPress'
3
+ import {
4
+ StepperArrow,
5
+ StepperButton,
6
+ StepperButtonProps,
7
+ } from './stepperButton'
5
8
 
6
- import { useNumberInputContext } from './context'
7
-
8
- export interface IncrementStepperProps {
9
- size?: number
10
- children?: ReactNode
11
- }
12
-
13
- export function IncrementStepper({
14
- size = 12,
15
- children,
16
- className,
17
- ...props
18
- }: IncrementStepperProps &
19
- Omit<ComponentPropsWithoutRef<'div'>, keyof IncrementStepperProps>) {
20
- const max = useNumberInputContext((s) => s.max)
21
- const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber)
22
- const readonly = useNumberInputContext((s) => s.readonly)
23
- const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange)
24
- const increment = useNumberInputContext((s) => s.increment)
25
-
26
- const press = useLongPress(increment)
9
+ export type IncrementStepperProps = StepperButtonProps
27
10
 
11
+ /** Raises the value by one `step`, repeating while held. */
12
+ export function IncrementStepper(
13
+ props: IncrementStepperProps &
14
+ Omit<ComponentPropsWithoutRef<'div'>, keyof IncrementStepperProps>,
15
+ ) {
28
16
  return (
29
- // eslint-disable-next-line jsx-a11y/role-supports-aria-props
30
- <div
31
- className={clsx('tremolo-number-input-increment-stepper', className)}
32
- role="button"
33
- tabIndex={-1}
34
- aria-disabled={
35
- keepWithinRange && valueAsNumber >= (max ?? Number.MAX_SAFE_INTEGER)
36
- }
37
- aria-readonly={readonly}
38
- onPointerDown={press}
17
+ <StepperButton
18
+ direction={1}
19
+ variant="increment"
20
+ icon={<StepperArrow up />}
39
21
  {...props}
40
- >
41
- {children || (
42
- <svg
43
- width={size}
44
- height={size}
45
- viewBox="0 0 24 24"
46
- fill="none"
47
- stroke="currentColor"
48
- strokeWidth="2"
49
- strokeLinecap="round"
50
- strokeLinejoin="round"
51
- >
52
- <polyline points="18 15 12 9 6 15" />
53
- </svg>
54
- )}
55
- </div>
22
+ />
56
23
  )
57
24
  }
@@ -0,0 +1,123 @@
1
+ import clsx from 'clsx'
2
+ import { ComponentPropsWithoutRef, CSSProperties, Ref } from 'react'
3
+
4
+ import { useComposedRefs } from '../_util/composeRefs'
5
+
6
+ import { useNumberInputContext } from './context'
7
+
8
+ export interface NumberInputFieldProps {
9
+ /**
10
+ * Select the text when the input takes focus. `'number'` selects the leading
11
+ * number, leaving whatever the format appended to it.
12
+ * @default 'none'
13
+ */
14
+ selectOnFocus?: 'all' | 'number' | 'none'
15
+ /**
16
+ * Commit and leave the input when Enter is pressed. Enter commits either way.
17
+ * @default true
18
+ */
19
+ blurOnEnter?: boolean
20
+
21
+ className?: string
22
+ style?: CSSProperties
23
+ ref?: Ref<HTMLInputElement>
24
+ }
25
+
26
+ /** The leading number of the displayed text, whatever the format put around it. */
27
+ const NUMBER_PREFIX = /^\s*-?[\d.,]*/
28
+
29
+ /**
30
+ * The text field of a `NumberInput`, and the only place the value can be typed.
31
+ *
32
+ * While the user types, their own text stands rather than `format(value)`, so
33
+ * that a half-finished entry is not rewritten under the caret.
34
+ */
35
+ export function InputField({
36
+ selectOnFocus = 'none',
37
+ blurOnEnter = true,
38
+ className,
39
+ style,
40
+ ref,
41
+ onFocus,
42
+ onBlur,
43
+ onKeyDown,
44
+ ...props
45
+ }: NumberInputFieldProps &
46
+ Omit<
47
+ ComponentPropsWithoutRef<'input'>,
48
+ keyof NumberInputFieldProps | 'type' | 'value' | 'defaultValue'
49
+ >) {
50
+ const {
51
+ value,
52
+ min,
53
+ max,
54
+ step,
55
+ disabled,
56
+ readonly,
57
+ keyboard,
58
+ text,
59
+ outOfRange,
60
+ setDraft,
61
+ commitDraft,
62
+ nudge,
63
+ inputRef,
64
+ } = useNumberInputContext()
65
+
66
+ const composedRef = useComposedRefs<HTMLInputElement>(ref, inputRef)
67
+
68
+ return (
69
+ <input
70
+ ref={composedRef}
71
+ className={clsx('tremolo-number-input-field', className)}
72
+ // Not type="number": that brings native spinners and a value the browser
73
+ // parses itself, neither of which survives a unit suffix.
74
+ type="text"
75
+ inputMode="decimal"
76
+ role="spinbutton"
77
+ value={text}
78
+ readOnly={readonly}
79
+ aria-disabled={disabled}
80
+ aria-readonly={readonly}
81
+ aria-valuenow={value}
82
+ aria-valuemin={min}
83
+ aria-valuemax={max}
84
+ aria-valuetext={text}
85
+ step={step}
86
+ data-out-of-range={outOfRange}
87
+ style={style}
88
+ onChange={(event) => setDraft(event.currentTarget.value)}
89
+ onFocus={(event) => {
90
+ const input = event.currentTarget
91
+ if (selectOnFocus == 'all') {
92
+ input.setSelectionRange(0, input.value.length)
93
+ } else if (selectOnFocus == 'number') {
94
+ input.setSelectionRange(
95
+ 0,
96
+ input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
97
+ )
98
+ }
99
+ onFocus?.(event)
100
+ }}
101
+ onBlur={(event) => {
102
+ commitDraft()
103
+ onBlur?.(event)
104
+ }}
105
+ onKeyDown={(event) => {
106
+ const key = event.key
107
+ if (key == 'Enter') {
108
+ commitDraft()
109
+ if (blurOnEnter) event.currentTarget.blur()
110
+ } else if (
111
+ keyboard &&
112
+ !readonly &&
113
+ (key == 'ArrowUp' || key == 'ArrowDown')
114
+ ) {
115
+ event.preventDefault()
116
+ nudge(key == 'ArrowUp' ? 1 : -1, keyboard)
117
+ }
118
+ onKeyDown?.(event)
119
+ }}
120
+ {...props}
121
+ />
122
+ )
123
+ }
@@ -1,25 +1,103 @@
1
1
  import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, ReactNode } from 'react'
2
+ import {
3
+ ComponentPropsWithoutRef,
4
+ CSSProperties,
5
+ ReactNode,
6
+ Ref,
7
+ useMemo,
8
+ useRef,
9
+ } from 'react'
10
+
11
+ import { applyDelta } from '@tremolo-ui/functions'
12
+
13
+ import { useDrag } from '../../hooks/useDrag'
14
+ import { useComposedRefs } from '../_util/composeRefs'
15
+
16
+ import { StepperProvider, useNumberInputContext } from './context'
3
17
 
4
18
  export interface StepperProps {
5
- /** Display only when hovering. */
6
- dynamic?: boolean
19
+ className?: string
20
+ style?: CSSProperties
21
+ /** `<NumberInput.IncrementStepper />` and `<NumberInput.DecrementStepper />` go here. */
7
22
  children?: ReactNode
23
+ ref?: Ref<HTMLDivElement>
8
24
  }
9
25
 
26
+ /**
27
+ * The area the steppers sit in, and a drag handle in its own right: dragging it
28
+ * up and down moves the value one `step` every `drag` pixels.
29
+ *
30
+ * The drag lives here rather than on `InputField` because `createDrag` turns
31
+ * off text selection on whatever element it is attached to.
32
+ */
10
33
  export function Stepper({
11
- dynamic = true,
12
- children,
13
34
  className,
35
+ style,
36
+ children,
37
+ ref,
14
38
  ...props
15
39
  }: StepperProps & Omit<ComponentPropsWithoutRef<'div'>, keyof StepperProps>) {
40
+ const { value, step, readonly, drag, range, changeValue } =
41
+ useNumberInputContext()
42
+
43
+ /**
44
+ * Set once the drag has actually moved the value, so that the press-and-hold
45
+ * repeat of a stepper stands down and leaves the value to the drag. A ref,
46
+ * because it is read from inside the repeat rather than rendered.
47
+ */
48
+ const draggingRef = useRef(false)
49
+ /**
50
+ * Where the drag started, taken on the first move rather than on pointer
51
+ * down: the steppers act on pointer down, so by then the value may already
52
+ * have been nudged once, and the drag should carry on from there.
53
+ */
54
+ const originRef = useRef<{ y: number; value: number } | null>(null)
55
+
56
+ const dragRefCallback = useDrag<HTMLDivElement>({
57
+ threshold: 1,
58
+ cursor: readonly ? undefined : 'ns-resize',
59
+ onDragStart: () => {
60
+ originRef.current = null
61
+ draggingRef.current = false
62
+ },
63
+ onDrag: (_x, y) => {
64
+ if (readonly || drag == null) return
65
+ if (!originRef.current) {
66
+ originRef.current = { y, value }
67
+ return
68
+ }
69
+ // Dragging up raises the value, as on a knob.
70
+ const steps = Math.round(-(y - originRef.current.y) / drag)
71
+ if (steps == 0) return
72
+ draggingRef.current = true
73
+ // The same pipeline the wheel and the arrow keys use. Counting from where
74
+ // the drag started keeps it from accumulating a rounding error.
75
+ changeValue(
76
+ applyDelta(originRef.current.value, steps, ['raw', step], range),
77
+ )
78
+ },
79
+ onDragEnd: () => {
80
+ draggingRef.current = false
81
+ },
82
+ })
83
+
84
+ const composedRef = useComposedRefs<HTMLDivElement>(
85
+ ref,
86
+ drag != null && !readonly ? dragRefCallback : undefined,
87
+ )
88
+
89
+ const context = useMemo(() => ({ draggingRef }), [])
90
+
16
91
  return (
17
- <div
18
- className={clsx('tremolo-number-input-stepper', className)}
19
- data-dynamic={dynamic}
20
- {...props}
21
- >
22
- {children}
23
- </div>
92
+ <StepperProvider value={context}>
93
+ <div
94
+ ref={composedRef}
95
+ className={clsx('tremolo-number-input-stepper', className)}
96
+ style={style}
97
+ {...props}
98
+ >
99
+ {children}
100
+ </div>
101
+ </StepperProvider>
24
102
  )
25
103
  }