@tremolo-ui/react 0.3.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 (64) hide show
  1. package/dist/index.cjs +1162 -1554
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +592 -278
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +592 -278
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1163 -1555
  9. package/dist/index.js.map +1 -1
  10. package/package.json +12 -13
  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 +95 -83
  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 +117 -52
  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 +136 -170
  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 +193 -250
  47. package/src/hooks/useDrag.ts +41 -76
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +51 -0
  51. package/src/index.ts +15 -13
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/DragObserver/index.tsx +0 -58
  54. package/src/components/NumberInput/InternalInput.tsx +0 -221
  55. package/src/components/NumberInput/type.ts +0 -58
  56. package/src/components/Piano/KeyLabel.tsx +0 -51
  57. package/src/components/Piano/context.tsx +0 -73
  58. package/src/components/Piano/key.tsx +0 -205
  59. package/src/components/WheelObserver/index.tsx +0 -48
  60. package/src/components/_util/type.ts +0 -1
  61. package/src/hooks/useDragWithElement.ts +0 -72
  62. package/src/hooks/usePianoDrag.ts +0 -75
  63. package/src/hooks/useRefCallbackEvent.ts +0 -34
  64. /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
@@ -2,32 +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'
20
- import { useRefCallbackEvent } from '../../hooks/useRefCallbackEvent'
21
- import {
22
- addUserSelectNone,
23
- Cursor,
24
- removeUserSelectNone,
25
- resetCursorStyle,
26
- setCursorStyle,
27
- } from '../_util'
21
+ import { useDragValue } from '../../hooks/useDragValue'
22
+ import { useWheel } from '../../hooks/useWheel'
23
+ import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
24
+ import { useComposedRefs } from '../_util/composeRefs'
28
25
 
29
26
  import { ActiveLine } from './ActiveLine'
30
- import { KnobProvider } from './context'
27
+ import { calcAngles, KnobProvider } from './context'
31
28
  import { InactiveLine } from './InactiveLine'
32
29
  import { SVGRoot } from './SVGRoot'
33
30
  import { Thumb } from './Thumb'
@@ -45,7 +42,16 @@ export interface KnobProps {
45
42
 
46
43
  // optional
47
44
  step?: number
48
- 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
49
55
  /**
50
56
  * value set when double-clicking
51
57
  * restriction: enableDoubleClickDefault = true
@@ -61,6 +67,10 @@ export interface KnobProps {
61
67
  startValue?: number
62
68
 
63
69
  /** width and height */
70
+ /**
71
+ * Width and height of the knob.
72
+ * Defaults to the `--knob-size` CSS variable (50px).
73
+ */
64
74
  size?: number | string
65
75
 
66
76
  /**
@@ -90,6 +100,21 @@ export interface KnobProps {
90
100
  angleRange?: number
91
101
 
92
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
93
118
  }
94
119
 
95
120
  export interface KnobMethods {
@@ -99,14 +124,20 @@ export interface KnobMethods {
99
124
 
100
125
  type Props = KnobProps & Omit<ComponentPropsWithoutRef<'div'>, keyof KnobProps>
101
126
 
102
- 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>(
103
134
  (
104
135
  {
105
136
  value,
106
137
  min,
107
138
  max,
108
139
  step = 1,
109
- skew = 1,
140
+ scale = linearScale,
110
141
  defaultValue = min,
111
142
  startValue = min,
112
143
  size,
@@ -128,35 +159,14 @@ const Root = forwardRef<KnobMethods, Props>(
128
159
  }: Props,
129
160
  forwardedRef,
130
161
  ) => {
131
- const [dragging, setDragging] = useState(false)
132
- const valueRef = useRef(0)
133
162
  const elmRef = useRef<HTMLElement | SVGElement>(null)
134
163
 
135
164
  const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
136
165
 
137
166
  // --- internal functions ---
138
- const onDrag = useCallback(
139
- (_x: number, y: number) => {
140
- if (!onChange || readonly) return
141
- const v = rawValue(valueRef.current - y / 100, min, max, skew)
142
- const clamped = clamp(stepValue(v, step), min, max)
143
- onChange(clamped)
144
- },
145
- [max, min, onChange, readonly, skew, step],
146
- )
147
-
148
- const updateValueByEvent = useCallback(
149
- (eventType: InputEventOption[0], x: number) => {
150
- let newValue
151
- if (eventType == 'normalized') {
152
- const n = normalizeValue(value, min, max, skew)
153
- newValue = rawValue(n + x, min, max, skew)
154
- } else {
155
- newValue = value + x
156
- }
157
- return clamp(stepValue(newValue, step), min, max)
158
- },
159
- [max, min, skew, step, value],
167
+ const range: ValueRange = useMemo(
168
+ () => ({ min, max, step, scale }),
169
+ [min, max, step, scale],
160
170
  )
161
171
 
162
172
  const handleKeyDown = useCallback(
@@ -165,45 +175,62 @@ const Root = forwardRef<KnobMethods, Props>(
165
175
  const key = event.key
166
176
  if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
167
177
  event.preventDefault()
168
- const x =
169
- key == 'ArrowRight' || key == 'ArrowUp' ? keyboard[1] : -keyboard[1]
170
- onChange(updateValueByEvent(keyboard[0], x))
178
+ const direction = key == 'ArrowRight' || key == 'ArrowUp' ? 1 : -1
179
+ onChange(applyDelta(value, direction, keyboard, range))
171
180
  }
172
181
  },
173
- [keyboard, onChange, readonly, updateValueByEvent],
182
+ [keyboard, onChange, readonly, value, range],
174
183
  )
175
184
 
176
185
  // --- hooks ---
177
- const [touchMoveRefCallback, pointerDownHandler] = useDrag<
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<
178
196
  HTMLElement | SVGElement
179
197
  >({
180
- onDrag: onDrag,
198
+ axis,
199
+ getValue: () => [value, value],
200
+ threshold: 1,
201
+ cursor: readonly ? undefined : externalStyles.cursor,
202
+ onChange: (v) => {
203
+ if (readonly) return
204
+ onChange?.(v[1])
205
+ },
181
206
  onDragStart: () => {
182
- setDragging(true)
183
- valueRef.current = normalizeValue(value, min, max, skew)
184
207
  if (externalStyles.userSelectNone) addUserSelectNone()
185
- if (externalStyles.cursor) setCursorStyle(externalStyles.cursor)
186
208
  },
187
209
  onDragEnd: () => {
188
- setDragging(false)
189
210
  if (externalStyles.userSelectNone) removeUserSelectNone()
190
- if (externalStyles.cursor) resetCursorStyle()
191
211
  },
192
212
  })
193
213
 
194
- const wheelRefCallback = useRefCallbackEvent(
195
- 'wheel',
196
- (event) => {
197
- if (!wheel || readonly) return
198
- event.preventDefault()
199
- if (!onChange || event.deltaY == 0) return
200
- const x = Math.sign(event.deltaY) * -wheel[1]
201
- onChange(updateValueByEvent(wheel[0], x))
202
- },
203
- { passive: false },
204
- [wheel, onChange, readonly, updateValueByEvent],
214
+ const wheelRefCallback = useWheel<HTMLElement>((event) => {
215
+ if (!wheel || readonly) return
216
+ event.preventDefault()
217
+ if (!onChange || event.deltaY == 0) return
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,
205
227
  )
206
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])
233
+
207
234
  useImperativeHandle(forwardedRef, () => {
208
235
  return {
209
236
  focus() {
@@ -216,21 +243,9 @@ const Root = forwardRef<KnobMethods, Props>(
216
243
  }, [])
217
244
 
218
245
  return (
219
- <KnobProvider
220
- value={value}
221
- min={min}
222
- max={max}
223
- step={step}
224
- skew={skew}
225
- startValue={startValue}
226
- angleRange={angleRange}
227
- >
246
+ <KnobProvider value={context}>
228
247
  <div
229
- ref={(elm) => {
230
- elmRef.current = elm
231
- wheelRefCallback(elm)
232
- touchMoveRefCallback(elm)
233
- }}
248
+ ref={rootRefCallback}
234
249
  className={clsx('tremolo-knob', className)}
235
250
  tabIndex={0}
236
251
  role="slider"
@@ -245,10 +260,7 @@ const Root = forwardRef<KnobMethods, Props>(
245
260
  height: size,
246
261
  ...style,
247
262
  }}
248
- onPointerDown={(event) => {
249
- pointerDownHandler(event)
250
- onPointerDown?.(event)
251
- }}
263
+ onPointerDown={onPointerDown}
252
264
  onDoubleClick={(event) => {
253
265
  if (enableDoubleClickDefault && onChange) {
254
266
  onChange(defaultValue)
@@ -261,7 +273,7 @@ const Root = forwardRef<KnobMethods, Props>(
261
273
  }}
262
274
  {...props}
263
275
  >
264
- {children || <SVGRoot />}
276
+ {children}
265
277
  </div>
266
278
  </KnobProvider>
267
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
  }