@tremolo-ui/react 0.8.0 → 0.9.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 (40) hide show
  1. package/dist/index.cjs +416 -252
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +256 -110
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +256 -110
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +368 -203
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/components/AnimationCanvas/index.tsx +4 -18
  11. package/src/components/FileInput/index.tsx +4 -1
  12. package/src/components/Knob/Thumb.tsx +19 -19
  13. package/src/components/Knob/index.tsx +85 -41
  14. package/src/components/NumberInput/InputField.tsx +9 -7
  15. package/src/components/NumberInput/Stepper.tsx +30 -6
  16. package/src/components/NumberInput/StepperButton.tsx +12 -4
  17. package/src/components/NumberInput/context.tsx +24 -2
  18. package/src/components/NumberInput/index.tsx +142 -27
  19. package/src/components/Piano/index.tsx +24 -7
  20. package/src/components/PointsEditor/Container.tsx +1 -1
  21. package/src/components/PointsEditor/Point.tsx +61 -30
  22. package/src/components/PointsEditor/context.tsx +3 -4
  23. package/src/components/PointsEditor/index.tsx +14 -15
  24. package/src/components/Slider/Marks.tsx +3 -2
  25. package/src/components/Slider/MarksOption.tsx +3 -2
  26. package/src/components/Slider/Thumb.tsx +20 -11
  27. package/src/components/Slider/Track.tsx +15 -13
  28. package/src/components/Slider/context.tsx +3 -3
  29. package/src/components/Slider/index.tsx +85 -48
  30. package/src/components/XYPad/Thumb.tsx +18 -8
  31. package/src/components/XYPad/context.tsx +1 -1
  32. package/src/components/XYPad/index.tsx +74 -42
  33. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  34. package/src/hooks/_internal/useChangeGesture.ts +93 -0
  35. package/src/hooks/_internal/useCheckSteps.ts +1 -1
  36. package/src/hooks/useAnimationFrame.ts +37 -15
  37. package/src/hooks/useEventListener.ts +93 -22
  38. package/src/hooks/useWheel.ts +4 -4
  39. package/src/index.ts +9 -5
  40. package/src/hooks/useInterval.ts +0 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tremolo-ui/react",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "React component library for audio app",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -22,18 +22,18 @@
22
22
  "clean:sb": "rimraf ./storybook-static"
23
23
  },
24
24
  "devDependencies": {
25
- "@storybook/addon-a11y": "10.6.0",
26
- "@storybook/addon-docs": "10.6.0",
27
- "@storybook/addon-links": "10.6.0",
28
- "@storybook/react-vite": "10.6.0",
25
+ "@storybook/addon-a11y": "10.6.1",
26
+ "@storybook/addon-docs": "10.6.1",
27
+ "@storybook/addon-links": "10.6.1",
28
+ "@storybook/react-vite": "10.6.1",
29
29
  "@testing-library/jest-dom": "7.0.1",
30
30
  "@testing-library/react": "16.3.3",
31
31
  "@types/react": "19.3.0",
32
32
  "@types/react-dom": "19.3.0",
33
- "jotai": "3.0.0",
33
+ "jotai": "3.0.1",
34
34
  "react": "19.3.0",
35
35
  "react-dom": "19.3.0",
36
- "storybook": "10.6.0",
36
+ "storybook": "10.6.1",
37
37
  "tone": "15.1.22"
38
38
  },
39
39
  "peerDependencies": {
@@ -41,8 +41,8 @@
41
41
  "react-dom": "^18 || ^19"
42
42
  },
43
43
  "dependencies": {
44
- "@tremolo-ui/dom": "^0.8.0",
45
- "@tremolo-ui/functions": "^0.8.0"
44
+ "@tremolo-ui/dom": "0.9.0",
45
+ "@tremolo-ui/functions": "^0.9.0"
46
46
  },
47
47
  "files": [
48
48
  "dist",
@@ -9,26 +9,12 @@ import {
9
9
  import {
10
10
  createAnimationCanvas,
11
11
  type AnimationCanvasInstance,
12
- type AnimationFrame,
12
+ type CanvasDrawFunction,
13
+ type CanvasInitFunction,
13
14
  } from '@tremolo-ui/dom'
14
15
 
15
16
  import { useComposedRefs } from '../../compose-refs'
16
17
 
17
- export type InitFunction = (
18
- context: CanvasRenderingContext2D,
19
- option: {
20
- /** current canvas width */
21
- width: number
22
- /** current canvas height */
23
- height: number
24
- },
25
- ) => void
26
-
27
- export type DrawFunction = (
28
- context: CanvasRenderingContext2D,
29
- option: AnimationFrame,
30
- ) => void
31
-
32
18
  export interface AnimationCanvasCommonProps {
33
19
  /**
34
20
  * Draw one frame. It is given the 2D context and the frame: the size in CSS
@@ -37,12 +23,12 @@ export interface AnimationCanvasCommonProps {
37
23
  * A new function replaces the old one in place, so writing it inline does
38
24
  * not restart anything.
39
25
  */
40
- draw: DrawFunction
26
+ draw: CanvasDrawFunction
41
27
  /**
42
28
  * Called once before the first frame, with the context and the size in CSS
43
29
  * pixels. Set up what every frame shares here.
44
30
  */
45
- init?: InitFunction
31
+ init?: CanvasInitFunction
46
32
  /**
47
33
  * Redraw on every animation frame. Turn it off to draw only when there is a
48
34
  * reason to: when the canvas mounts, when it is resized, and when the
@@ -7,7 +7,10 @@ import {
7
7
  useMemo,
8
8
  } from 'react'
9
9
 
10
- import { partitionByAccept, visuallyHiddenStyle } from '@tremolo-ui/dom'
10
+ import {
11
+ partitionByAccept,
12
+ visuallyHiddenStyle,
13
+ } from '@tremolo-ui/dom/internal'
11
14
 
12
15
  import { FileInputProvider } from './context'
13
16
  import { Trigger } from './Trigger'
@@ -12,46 +12,46 @@ export interface KnobThumbProps {
12
12
  * Fill colour of the circle.
13
13
  * @default 'currentColor'
14
14
  */
15
- thumb?: string
15
+ color?: string
16
16
  /**
17
17
  * Colour of the line that points at the value.
18
18
  * @default 'currentColor'
19
19
  */
20
- thumbLine?: string
20
+ lineColor?: string
21
21
  /**
22
22
  * Diameter of the circle, as a percentage of the knob.
23
23
  * @default 84
24
24
  */
25
- thumbSize?: number
25
+ size?: number
26
26
  /**
27
27
  * Thickness of the line, as a percentage of the knob.
28
28
  * @default 6
29
29
  */
30
- thumbLineWeight?: number
30
+ lineWeight?: number
31
31
  /**
32
32
  * How far down the line reaches, as a percentage of the knob from its top.
33
33
  * The line starts at the edge of the circle.
34
34
  * @default 35
35
35
  */
36
- thumbLineLength?: number
36
+ lineLength?: number
37
37
 
38
38
  /**
39
- * Classes for what the thumb draws inside itself: `thumbLine` is the line
40
- * that points at the value.
39
+ * Classes for what the thumb draws inside itself: `line` is the line that
40
+ * points at the value.
41
41
  */
42
42
  classes?: {
43
- thumbLine?: string
43
+ line?: string
44
44
  }
45
45
  }
46
46
 
47
47
  export function Thumb({
48
48
  className,
49
49
 
50
- thumb = 'currentColor',
51
- thumbLine = 'currentColor',
52
- thumbSize = 84,
53
- thumbLineWeight = 6,
54
- thumbLineLength = 35,
50
+ color = 'currentColor',
51
+ lineColor = 'currentColor',
52
+ size = 84,
53
+ lineWeight = 6,
54
+ lineLength = 35,
55
55
  classes,
56
56
 
57
57
  ...props
@@ -65,15 +65,15 @@ export function Thumb({
65
65
 
66
66
  return (
67
67
  <svg className={className} {...props}>
68
- <circle cx="50%" cy="50%" r={`${thumbSize / 2}%`} fill={thumb} />
68
+ <circle cx="50%" cy="50%" r={`${size / 2}%`} fill={color} />
69
69
  <line
70
- className={classes?.thumbLine}
70
+ className={classes?.line}
71
71
  x1="50%"
72
- y1={`${(KNOB_VIEWBOX_SIZE - clamp(thumbSize, 0, 100)) / 2}%`}
72
+ y1={`${(KNOB_VIEWBOX_SIZE - clamp(size, 0, 100)) / 2}%`}
73
73
  x2="50%"
74
- y2={`${thumbLineLength}%`}
75
- stroke={thumbLine}
76
- strokeWidth={`${thumbLineWeight}%`}
74
+ y2={`${lineLength}%`}
75
+ stroke={lineColor}
76
+ strokeWidth={`${lineWeight}%`}
77
77
  // NOTE
78
78
  // https://bugs.webkit.org/show_bug.cgi?id=201854
79
79
  // https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/transform-origin#browser_compatibility
@@ -3,6 +3,7 @@ import {
3
3
  CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
+ Ref,
6
7
  useCallback,
7
8
  useImperativeHandle,
8
9
  useMemo,
@@ -10,23 +11,27 @@ import {
10
11
  } from 'react'
11
12
 
12
13
  import {
13
- applyDelta,
14
- arrowKeyDirection,
15
14
  type AxisOptions,
16
- cssLength,
15
+ type ChangeSource,
17
16
  DEFAULT_DRAG_SENSITIVITY,
18
17
  DEFAULT_KEYBOARD_OPTIONS,
19
18
  DEFAULT_WHEEL_OPTIONS,
20
19
  type InputEventOption,
21
20
  knobAngles,
22
21
  type ModifierValue,
23
- selectModifier,
24
- wheelDirection,
25
22
  type XY,
26
23
  } from '@tremolo-ui/dom'
24
+ import {
25
+ applyDelta,
26
+ arrowKeyDirection,
27
+ cssLength,
28
+ selectModifier,
29
+ wheelDirection,
30
+ } from '@tremolo-ui/dom/internal'
27
31
  import { linearScale, type Scale, type ValueRange } from '@tremolo-ui/functions'
28
32
 
29
33
  import { useComposedRefs } from '../../compose-refs'
34
+ import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
30
35
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
31
36
  import { useDragValue } from '../../hooks/useDragValue'
32
37
  import { useWheel } from '../../hooks/useWheel'
@@ -39,10 +44,6 @@ import { Thumb } from './Thumb'
39
44
 
40
45
  import type { CSSVariables } from '../../css-variables'
41
46
 
42
- const defaultExternalStyles: KnobProps['externalStyles'] = {
43
- cursor: 'grabbing',
44
- }
45
-
46
47
  export interface KnobProps {
47
48
  /** The current value. The knob shows only this, so update it from `onChange`. */
48
49
  value: number
@@ -69,10 +70,10 @@ export interface KnobProps {
69
70
  */
70
71
  scale?: Scale
71
72
  /**
72
- * The value a double click restores, while `enableDoubleClickDefault` is on.
73
- * @default min
73
+ * The value a double click restores. `null` turns the double click off.
74
+ * @default startValue
74
75
  */
75
- defaultValue?: number
76
+ resetValue?: number | null
76
77
 
77
78
  /**
78
79
  * Where the active arc starts. Put it at the centre of a bipolar control,
@@ -91,11 +92,9 @@ export interface KnobProps {
91
92
  * The cursor to show while dragging. It is set on the dragged element, so it
92
93
  * stays while the pointer is outside the knob.
93
94
  *
94
- * @default { cursor: 'grabbing' }
95
+ * @default 'grabbing'
95
96
  */
96
- externalStyles?: {
97
- cursor?: CSSProperties['cursor']
98
- }
97
+ dragCursor?: CSSProperties['cursor']
99
98
  /**
100
99
  * How much one notch of the wheel moves the value. It only acts while the
101
100
  * focus is inside, so that scrolling the page past the knob leaves it alone.
@@ -158,11 +157,6 @@ export interface KnobProps {
158
157
  * @default { default: ['raw', 1], shift: ['raw', 0.1] }
159
158
  */
160
159
  keyboard?: ModifierValue<InputEventOption> | null
161
- /**
162
- * Restore `defaultValue` on a double click.
163
- * @default true
164
- */
165
- enableDoubleClickDefault?: boolean
166
160
 
167
161
  /**
168
162
  * Make the knob unchangeable and remove it from the tab order.
@@ -173,7 +167,7 @@ export interface KnobProps {
173
167
  * Make the knob unchangeable while leaving it focusable.
174
168
  * The parts carry `data-readonly` while it is set.
175
169
  */
176
- readonly?: boolean
170
+ readOnly?: boolean
177
171
 
178
172
  /**
179
173
  * How far the knob turns from `min` to `max`, in degrees, centred on the
@@ -187,6 +181,25 @@ export interface KnobProps {
187
181
  * click moves it.
188
182
  */
189
183
  onChange?: (value: number) => void
184
+ /**
185
+ * Called when a change of the value starts — a drag, a double click, the first wheel notch
186
+ * or arrow key — with the value
187
+ * before it and what it is made with. A host recording automation can treat
188
+ * the control as touched from here until `onChangeEnd`.
189
+ */
190
+ onChangeStart?: (value: number, source: ChangeSource) => void
191
+ /**
192
+ * Called when the change ends, with the value it ended on: on release, or
193
+ * `changeEndDelay` after the last wheel notch or arrow key.
194
+ */
195
+ onChangeEnd?: (value: number, source: ChangeSource) => void
196
+ /**
197
+ * How long after the last wheel notch or arrow key the change counts as
198
+ * over, in milliseconds. Neither has an event that says it is done.
199
+ *
200
+ * @default 500
201
+ */
202
+ changeEndDelay?: number
190
203
 
191
204
  /**
192
205
  * The knob renders exactly what you compose here; there is no default
@@ -204,6 +217,13 @@ export interface KnobProps {
204
217
  children: ReactNode
205
218
 
206
219
  style?: CSSProperties & CSSVariables<'knob-size'>
220
+
221
+ /**
222
+ * Receives `focus` and `blur`. They do nothing while the knob is disabled,
223
+ * where the element's own `focus()` would still focus it. `ref` reaches the
224
+ * root element itself.
225
+ */
226
+ actionsRef?: Ref<KnobMethods>
207
227
  }
208
228
 
209
229
  export interface KnobMethods {
@@ -219,7 +239,7 @@ type Props = KnobProps & Omit<ComponentPropsWithoutRef<'div'>, keyof KnobProps>
219
239
  */
220
240
  const WHEEL_OPTIONS = { requireFocus: true }
221
241
 
222
- export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
242
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
223
243
  (
224
244
  {
225
245
  value,
@@ -227,33 +247,35 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
227
247
  max,
228
248
  step = 1,
229
249
  scale = linearScale,
230
- defaultValue = min,
250
+ resetValue,
231
251
  startValue = min,
232
252
  size,
233
- externalStyles: _externalStyles,
253
+ dragCursor = 'grabbing',
234
254
  wheel = DEFAULT_WHEEL_OPTIONS,
235
255
  keyboard = DEFAULT_KEYBOARD_OPTIONS,
236
256
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
237
257
  pointerLock = false,
238
- enableDoubleClickDefault = true,
239
258
  disabled = false,
240
- readonly = false,
259
+ readOnly = false,
241
260
  angleRange = 270,
242
261
  onChange,
262
+ onChangeStart,
263
+ onChangeEnd,
264
+ changeEndDelay,
243
265
  onKeyDown,
244
266
  onPointerDown,
245
267
  onDoubleClick,
246
268
  className,
247
269
  style,
248
270
  children,
271
+ actionsRef,
249
272
  ...props
250
273
  }: Props,
251
274
  forwardedRef,
252
275
  ) => {
253
- const elmRef = useRef<HTMLElement | SVGElement>(null)
276
+ const elmRef = useRef<HTMLDivElement>(null)
254
277
 
255
- const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
256
- const inactive = disabled || readonly
278
+ const inactive = disabled || readOnly
257
279
 
258
280
  // --- internal functions ---
259
281
  const range: ValueRange = useMemo(
@@ -263,15 +285,29 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
263
285
 
264
286
  useCheckSteps({ component: 'Knob', range, keyboard, wheel })
265
287
 
288
+ const gesture = useChangeGesture(
289
+ value,
290
+ { onChangeStart, onChangeEnd, changeEndDelay },
291
+ inactive,
292
+ )
293
+ const change = useCallback(
294
+ (next: number) => {
295
+ gesture.changed(next)
296
+ onChange?.(next)
297
+ },
298
+ [gesture, onChange],
299
+ )
300
+
266
301
  const handleKeyDown = useCallback(
267
302
  (event: React.KeyboardEvent<HTMLOrSVGElement>) => {
268
303
  if (!keyboard || !onChange || inactive) return
269
304
  const direction = arrowKeyDirection(event.key)
270
305
  if (direction === null) return
271
306
  event.preventDefault()
272
- onChange(applyDelta(value, direction, keyboard, range, event))
307
+ gesture.pulse('keyboard')
308
+ change(applyDelta(value, direction, keyboard, range, event))
273
309
  },
274
- [keyboard, onChange, inactive, value, range],
310
+ [keyboard, onChange, inactive, value, range, gesture, change],
275
311
  )
276
312
 
277
313
  // --- hooks ---
@@ -292,13 +328,17 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
292
328
  sensitivity: (state) =>
293
329
  selectModifier(dragSensitivity, state.event).value,
294
330
  threshold: 1,
295
- cursor: inactive ? undefined : externalStyles.cursor,
331
+ cursor: inactive ? undefined : dragCursor,
296
332
  pointerLock: inactive ? false : pointerLock,
297
333
  shouldStart: () => !inactive,
298
334
  onChange: (v) => {
299
335
  if (inactive) return
300
- onChange?.(v[1])
336
+ change(v[1])
337
+ },
338
+ onDragStart: () => {
339
+ if (!inactive) gesture.hold('pointer')
301
340
  },
341
+ onDragEnd: () => gesture.end(),
302
342
  })
303
343
 
304
344
  const wheelRefCallback = useWheel<HTMLElement>((event) => {
@@ -309,12 +349,14 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
309
349
  if (direction === null) return
310
350
  event.preventDefault()
311
351
  if (!onChange) return
312
- onChange(applyDelta(value, direction, wheel, range, event))
352
+ gesture.pulse('wheel')
353
+ change(applyDelta(value, direction, wheel, range, event))
313
354
  }, WHEEL_OPTIONS)
314
355
 
315
356
  // Composed once, so React attaches the refs a single time instead of
316
357
  // detaching and re-attaching on every render.
317
- const rootRefCallback = useComposedRefs<HTMLElement | SVGElement>(
358
+ const rootRefCallback = useComposedRefs<HTMLDivElement>(
359
+ forwardedRef,
318
360
  elmRef,
319
361
  dragRefCallback,
320
362
  wheelRefCallback,
@@ -325,7 +367,7 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
325
367
  return { ...config, ...knobAngles(config) }
326
368
  }, [value, min, max, step, scale, startValue, angleRange])
327
369
 
328
- useImperativeHandle(forwardedRef, () => {
370
+ useImperativeHandle(actionsRef, () => {
329
371
  return {
330
372
  focus() {
331
373
  if (!disabled) elmRef.current?.focus()
@@ -347,9 +389,9 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
347
389
  aria-valuemin={min}
348
390
  aria-valuemax={max}
349
391
  aria-disabled={disabled}
350
- aria-readonly={readonly}
392
+ aria-readonly={readOnly}
351
393
  data-disabled={disabled ? '' : undefined}
352
- data-readonly={readonly ? '' : undefined}
394
+ data-readonly={readOnly ? '' : undefined}
353
395
  data-dragging={dragging ? '' : undefined}
354
396
  style={
355
397
  {
@@ -359,8 +401,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
359
401
  }
360
402
  onPointerDown={onPointerDown}
361
403
  onDoubleClick={(event) => {
362
- if (!inactive && enableDoubleClickDefault && onChange) {
363
- onChange(defaultValue)
404
+ if (!inactive && resetValue !== null && onChange) {
405
+ gesture.instant('doubleClick', () =>
406
+ change(resetValue ?? startValue),
407
+ )
364
408
  }
365
409
  onDoubleClick?.(event)
366
410
  }}
@@ -11,11 +11,11 @@ import {
11
11
  caretAtDecimalOffset,
12
12
  caretDecimalOffset,
13
13
  numberSpan,
14
- } from '@tremolo-ui/dom'
14
+ } from '@tremolo-ui/dom/internal'
15
15
 
16
16
  import { useComposedRefs } from '../../compose-refs'
17
17
 
18
- import { useNumberInputContext } from './context'
18
+ import { useNumberInputContext, useNumberInputGesture } from './context'
19
19
 
20
20
  // The value belongs to `NumberInput.Root`, and the field is always text.
21
21
  type Props = Omit<
@@ -43,7 +43,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
43
43
  max,
44
44
  step,
45
45
  disabled,
46
- readonly,
46
+ readOnly,
47
47
  keyboard,
48
48
  selectOnFocus,
49
49
  unformatOnFocus,
@@ -58,6 +58,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
58
58
  inputRef,
59
59
  } = useNumberInputContext()
60
60
 
61
+ const gesture = useNumberInputGesture()
61
62
  const [focused, setFocused] = useState(false)
62
63
 
63
64
  // Only until the first keystroke: from then on the draft is the user's own
@@ -119,11 +120,11 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
119
120
  role="spinbutton"
120
121
  value={shown}
121
122
  disabled={disabled}
122
- readOnly={readonly}
123
+ readOnly={readOnly}
123
124
  aria-disabled={disabled}
124
- aria-readonly={readonly}
125
+ aria-readonly={readOnly}
125
126
  data-disabled={disabled ? '' : undefined}
126
- data-readonly={readonly ? '' : undefined}
127
+ data-readonly={readOnly ? '' : undefined}
127
128
  aria-valuenow={value}
128
129
  aria-valuemin={min}
129
130
  aria-valuemax={max}
@@ -159,7 +160,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
159
160
  } else if (
160
161
  keyboard &&
161
162
  !disabled &&
162
- !readonly &&
163
+ !readOnly &&
163
164
  (key === 'ArrowUp' || key === 'ArrowDown') &&
164
165
  // Arrow keys pick a candidate while an IME is converting. Stepping
165
166
  // the value there would fight the conversion, and moving the caret
@@ -174,6 +175,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
174
175
  from: input.value,
175
176
  }
176
177
  }
178
+ gesture?.key()
177
179
  nudge(key === 'ArrowUp' ? 1 : -1, keyboard, event)
178
180
  }
179
181
  onKeyDown?.(event)
@@ -7,11 +7,18 @@ import {
7
7
  useState,
8
8
  } from 'react'
9
9
 
10
- import { createStepperDrag, type StepperDragInstance } from '@tremolo-ui/dom'
10
+ import {
11
+ createStepperDrag,
12
+ type StepperDragInstance,
13
+ } from '@tremolo-ui/dom/internal'
11
14
 
12
15
  import { useComposedRefs } from '../../compose-refs'
13
16
 
14
- import { StepperProvider, useNumberInputContext } from './context'
17
+ import {
18
+ StepperProvider,
19
+ useNumberInputContext,
20
+ useNumberInputGesture,
21
+ } from './context'
15
22
 
16
23
  type Props = ComponentPropsWithoutRef<'div'>
17
24
 
@@ -24,21 +31,25 @@ type Props = ComponentPropsWithoutRef<'div'>
24
31
  * off text selection on whatever element it is attached to.
25
32
  */
26
33
  export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
27
- function Stepper({ className, style, children, ...props }, forwardedRef) {
34
+ function Stepper(
35
+ { className, style, children, onPointerDown, ...props },
36
+ forwardedRef,
37
+ ) {
28
38
  const {
29
39
  value,
30
40
  disabled,
31
- readonly,
41
+ readOnly,
32
42
  drag,
33
43
  dragSensitivity,
34
44
  pointerLock,
45
+ dragCursor,
35
46
  rawRange,
36
47
  changeValue,
37
48
  } = useNumberInputContext()
38
49
  // Only attached while it can do something: `createDrag` puts
39
50
  // `touch-action: none` on the element, and a stepper that cannot be
40
51
  // dragged has no reason to stop the page scrolling under a finger.
41
- const enabled = drag !== null && !disabled && !readonly
52
+ const enabled = drag !== null && !disabled && !readOnly
42
53
 
43
54
  // See useDrag for why the node is held in state rather than a ref.
44
55
  const [node, setNode] = useState<HTMLDivElement | null>(null)
@@ -49,6 +60,7 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
49
60
  pixels: drag ?? 1,
50
61
  sensitivity: dragSensitivity,
51
62
  pointerLock,
63
+ cursor: dragCursor,
52
64
  }
53
65
  const optionsRef = useRef(options)
54
66
 
@@ -73,6 +85,7 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
73
85
  }
74
86
  }, [node, enabled])
75
87
 
88
+ const gesture = useNumberInputGesture()
76
89
  const composedRef = useComposedRefs<HTMLDivElement>(forwardedRef, setNode)
77
90
 
78
91
  const context = useMemo(
@@ -82,7 +95,18 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
82
95
 
83
96
  return (
84
97
  <StepperProvider value={context}>
85
- <div ref={composedRef} className={className} style={style} {...props}>
98
+ <div
99
+ ref={composedRef}
100
+ className={className}
101
+ style={style}
102
+ onPointerDown={(event) => {
103
+ // A drag on the stepper is a press like any other: held from here
104
+ // until the pointer is released.
105
+ if (enabled) gesture?.press()
106
+ onPointerDown?.(event)
107
+ }}
108
+ {...props}
109
+ >
86
110
  {children}
87
111
  </div>
88
112
  </StepperProvider>
@@ -2,7 +2,11 @@ import { ComponentPropsWithoutRef, ReactNode, CSSProperties } from 'react'
2
2
 
3
3
  import { useLongPress } from '../../hooks/useLongPress'
4
4
 
5
- import { useNumberInputContext, useStepperContext } from './context'
5
+ import {
6
+ useNumberInputContext,
7
+ useNumberInputGesture,
8
+ useStepperContext,
9
+ } from './context'
6
10
 
7
11
  import type { CSSVariables } from '../../css-variables'
8
12
 
@@ -37,9 +41,10 @@ export function StepperButton({
37
41
  variant: 'increment' | 'decrement'
38
42
  icon: ReactNode
39
43
  }) {
40
- const { step, disabled, readonly, atMin, atMax, nudge } =
44
+ const { step, disabled, readOnly, atMin, atMax, nudge } =
41
45
  useNumberInputContext()
42
46
  const stepper = useStepperContext()
47
+ const gesture = useNumberInputGesture()
43
48
 
44
49
  // Not memoized: `useLongPress` only ever calls it through a ref, so a fresh
45
50
  // identity per render costs nothing.
@@ -64,10 +69,13 @@ export function StepperButton({
64
69
  data-disabled={
65
70
  disabled || (direction > 0 ? atMax : atMin) ? '' : undefined
66
71
  }
67
- data-readonly={readonly ? '' : undefined}
72
+ data-readonly={readOnly ? '' : undefined}
68
73
  style={style}
69
74
  onPointerDown={(event) => {
70
- if (!disabled && !readonly) press(event)
75
+ if (!disabled && !readOnly) {
76
+ gesture?.press()
77
+ press(event)
78
+ }
71
79
  onPointerDown?.(event)
72
80
  }}
73
81
  {...props}