@tremolo-ui/react 0.7.0 → 0.8.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 (47) hide show
  1. package/dist/index.cjs +346 -1015
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +76 -221
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +76 -221
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +333 -978
  8. package/dist/index.js.map +1 -1
  9. package/package.json +3 -3
  10. package/src/components/DropZone/index.tsx +2 -1
  11. package/src/components/FileInput/index.tsx +8 -13
  12. package/src/components/Knob/ActiveLine.tsx +5 -3
  13. package/src/components/Knob/InactiveLine.tsx +6 -4
  14. package/src/components/Knob/SVGRoot.tsx +3 -3
  15. package/src/components/Knob/Thumb.tsx +3 -2
  16. package/src/components/Knob/context.tsx +3 -74
  17. package/src/components/Knob/index.tsx +19 -16
  18. package/src/components/NumberInput/InputField.tsx +12 -27
  19. package/src/components/NumberInput/Stepper.tsx +49 -97
  20. package/src/components/NumberInput/StepperButton.tsx +3 -4
  21. package/src/components/NumberInput/context.tsx +2 -1
  22. package/src/components/NumberInput/index.tsx +40 -71
  23. package/src/components/Piano/index.tsx +35 -142
  24. package/src/components/PointsEditor/Container.tsx +15 -26
  25. package/src/components/PointsEditor/Point.tsx +38 -85
  26. package/src/components/PointsEditor/context.tsx +5 -56
  27. package/src/components/PointsEditor/index.tsx +29 -311
  28. package/src/components/Slider/Marks.tsx +3 -5
  29. package/src/components/Slider/MarksOption.tsx +7 -18
  30. package/src/components/Slider/Track.tsx +2 -1
  31. package/src/components/Slider/index.tsx +29 -28
  32. package/src/components/XYPad/Area.tsx +27 -49
  33. package/src/components/XYPad/Thumb.tsx +3 -1
  34. package/src/components/XYPad/context.tsx +1 -3
  35. package/src/components/XYPad/index.tsx +33 -46
  36. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  37. package/src/hooks/_internal/useCheckSteps.ts +9 -146
  38. package/src/hooks/useLongPress.ts +35 -51
  39. package/src/hooks/useMIDIAccess.ts +0 -8
  40. package/src/hooks/useMIDIInput.ts +0 -3
  41. package/src/index.ts +2 -27
  42. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  43. package/src/components/Slider/type.ts +0 -46
  44. package/src/components/_util/css-length.ts +0 -13
  45. package/src/components/_util/decimal-digits.ts +0 -23
  46. package/src/components/_util/visually-hidden.ts +0 -25
  47. package/src/input-event.ts +0 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tremolo-ui/react",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "React component library for audio app",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,8 +41,8 @@
41
41
  "react-dom": "^18 || ^19"
42
42
  },
43
43
  "dependencies": {
44
- "@tremolo-ui/dom": "^0.7.0",
45
- "@tremolo-ui/functions": "^0.7.0"
44
+ "@tremolo-ui/dom": "^0.8.0",
45
+ "@tremolo-ui/functions": "^0.8.0"
46
46
  },
47
47
  "files": [
48
48
  "dist",
@@ -39,7 +39,8 @@ export interface DropZoneProps {
39
39
  onDrop?: (files: File[], event: DragEvent) => void
40
40
  /**
41
41
  * Called with the dropped files that do not match `accept`, so that the
42
- * reason can be shown.
42
+ * reason can be shown. It comes after `onDrop` for the same drop, so a list
43
+ * of rejected files can be cleared in `onDrop` and filled here.
43
44
  */
44
45
  onReject?: (files: File[], event: DragEvent) => void
45
46
 
@@ -7,9 +7,7 @@ import {
7
7
  useMemo,
8
8
  } from 'react'
9
9
 
10
- import { matchesAccept } from '@tremolo-ui/dom'
11
-
12
- import { visuallyHiddenStyle } from '../_util/visually-hidden'
10
+ import { partitionByAccept, visuallyHiddenStyle } from '@tremolo-ui/dom'
13
11
 
14
12
  import { FileInputProvider } from './context'
15
13
  import { Trigger } from './Trigger'
@@ -47,7 +45,8 @@ export interface FileInputProps {
47
45
  /**
48
46
  * Called with the files that were picked and do not match `accept`, so that
49
47
  * the reason can be shown. Given alongside `onChange` when a selection held
50
- * both.
48
+ * both, and after it, so a list of rejected files can be cleared in
49
+ * `onChange` and filled here.
51
50
  */
52
51
  onReject?: (files: File[]) => void
53
52
 
@@ -92,22 +91,18 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
92
91
  const context = useMemo(() => ({ inputId, disabled }), [inputId, disabled])
93
92
 
94
93
  function handleChange(event: ChangeEvent<HTMLInputElement>) {
95
- const picked = Array.from(event.target.files ?? [])
94
+ const { accepted, rejected } = partitionByAccept(
95
+ Array.from(event.target.files ?? []),
96
+ accept,
97
+ )
96
98
 
97
99
  // Picking the same file twice fires no second change event while the
98
100
  // value is still on the input, so it is cleared as soon as it is read.
99
101
  // Nothing is lost: the files are already in hand.
100
102
  event.target.value = ''
101
103
 
102
- const accepted: File[] = []
103
- const rejected: File[] = []
104
- for (const file of picked) {
105
- if (matchesAccept(file, accept)) accepted.push(file)
106
- else rejected.push(file)
107
- }
108
-
109
- if (rejected.length > 0) onReject?.(rejected)
110
104
  if (accepted.length > 0) onChange?.(accepted)
105
+ if (rejected.length > 0) onReject?.(rejected)
111
106
  }
112
107
 
113
108
  return (
@@ -1,8 +1,10 @@
1
1
  import { SVGProps } from 'react'
2
2
 
3
+ import { knobArcPath, knobArcRadius } from '@tremolo-ui/dom'
4
+
3
5
  import { useCheckPlacement } from '../_util/Placement'
4
6
 
5
- import { arcPath, arcRadius, useKnobContext } from './context'
7
+ import { useKnobContext } from './context'
6
8
 
7
9
  export function ActiveLine({
8
10
  stroke = 'currentColor',
@@ -15,12 +17,12 @@ export function ActiveLine({
15
17
  const r2 = useKnobContext((s) => s.r2)
16
18
  const r3 = useKnobContext((s) => s.r3)
17
19
 
18
- const radius = arcRadius(strokeWidth)
20
+ const radius = knobArcRadius(strokeWidth)
19
21
 
20
22
  return (
21
23
  <path
22
24
  className={className}
23
- d={arcPath(r2, r3, radius)}
25
+ d={knobArcPath(r2, r3, radius)}
24
26
  fill="none"
25
27
  stroke={stroke}
26
28
  strokeWidth={strokeWidth}
@@ -1,8 +1,10 @@
1
1
  import { SVGProps } from 'react'
2
2
 
3
+ import { knobArcPath, knobArcRadius } from '@tremolo-ui/dom'
4
+
3
5
  import { useCheckPlacement } from '../_util/Placement'
4
6
 
5
- import { arcPath, arcRadius, useKnobContext } from './context'
7
+ import { useKnobContext } from './context'
6
8
 
7
9
  export function InactiveLine({
8
10
  stroke = 'currentColor',
@@ -21,14 +23,14 @@ export function InactiveLine({
21
23
  const r3 = useKnobContext((s) => s.r3)
22
24
  const r4 = useKnobContext((s) => s.r4)
23
25
 
24
- const radius = arcRadius(strokeWidth)
26
+ const radius = knobArcRadius(strokeWidth)
25
27
 
26
28
  return (
27
29
  <>
28
30
  {startValue > min && (
29
31
  <path
30
32
  className={className}
31
- d={arcPath(r1, r2, radius)}
33
+ d={knobArcPath(r1, r2, radius)}
32
34
  fill="none"
33
35
  stroke={stroke}
34
36
  strokeWidth={strokeWidth}
@@ -38,7 +40,7 @@ export function InactiveLine({
38
40
  {startValue < max && (
39
41
  <path
40
42
  className={className}
41
- d={arcPath(r3, r4, radius)}
43
+ d={knobArcPath(r3, r4, radius)}
42
44
  fill="none"
43
45
  stroke={stroke}
44
46
  strokeWidth={strokeWidth}
@@ -1,8 +1,8 @@
1
1
  import { ReactNode, SVGProps } from 'react'
2
2
 
3
- import { Placement } from '../_util/Placement'
3
+ import { KNOB_VIEWBOX_SIZE } from '@tremolo-ui/dom'
4
4
 
5
- import { viewBoxSize } from './context'
5
+ import { Placement } from '../_util/Placement'
6
6
 
7
7
  export interface KnobSVGRootProps {
8
8
  /**
@@ -19,7 +19,7 @@ export function SVGRoot({
19
19
  }: KnobSVGRootProps & Omit<SVGProps<SVGSVGElement>, keyof KnobSVGRootProps>) {
20
20
  return (
21
21
  <svg
22
- viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
22
+ viewBox={`0 0 ${KNOB_VIEWBOX_SIZE} ${KNOB_VIEWBOX_SIZE}`}
23
23
  style={{
24
24
  display: 'block',
25
25
  ...style,
@@ -1,10 +1,11 @@
1
1
  import { SVGProps } from 'react'
2
2
 
3
+ import { KNOB_VIEWBOX_SIZE } from '@tremolo-ui/dom'
3
4
  import { clamp } from '@tremolo-ui/functions'
4
5
 
5
6
  import { useCheckPlacement } from '../_util/Placement'
6
7
 
7
- import { useKnobContext, viewBoxSize } from './context'
8
+ import { useKnobContext } from './context'
8
9
 
9
10
  export interface KnobThumbProps {
10
11
  /**
@@ -68,7 +69,7 @@ export function Thumb({
68
69
  <line
69
70
  className={classes?.thumbLine}
70
71
  x1="50%"
71
- y1={`${(viewBoxSize - clamp(thumbSize, 0, 100)) / 2}%`}
72
+ y1={`${(KNOB_VIEWBOX_SIZE - clamp(thumbSize, 0, 100)) / 2}%`}
72
73
  x2="50%"
73
74
  y2={`${thumbLineLength}%`}
74
75
  stroke={thumbLine}
@@ -1,9 +1,7 @@
1
1
  import { createContext, useContext } from 'react'
2
2
 
3
- import { radian, type Scale } from '@tremolo-ui/functions'
4
-
5
- export const viewBoxSize = 100
6
- export const center = viewBoxSize / 2
3
+ import { type KnobAngles } from '@tremolo-ui/dom'
4
+ import { type Scale } from '@tremolo-ui/functions'
7
5
 
8
6
  export type KnobConfig = {
9
7
  value: number
@@ -16,76 +14,7 @@ export type KnobConfig = {
16
14
  angleRange: number
17
15
  }
18
16
 
19
- export type KnobContextValue = KnobConfig & {
20
- /** normalized value */
21
- p: number
22
- /** ロータリー開始位置 */
23
- r1: number
24
- /** activeLine の開始位置 */
25
- r2: number
26
- /** activeLine の終了位置 */
27
- r3: number
28
- /** ロータリー終了位置 */
29
- r4: number
30
- }
31
-
32
- /**
33
- * 角度から円弧上の点を求める。
34
- *
35
- * 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
36
- * それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
37
- */
38
- export function pointOnArc(angle: number, radius: number) {
39
- return {
40
- x: center + radius * Math.cos(radian(angle - 90)),
41
- y: center + radius * Math.sin(radian(angle - 90)),
42
- }
43
- }
44
-
45
- /** 線の太さが viewBox からはみ出さないようにした半径 */
46
- export function arcRadius(strokeWidth: number | string | undefined) {
47
- const width =
48
- typeof strokeWidth === 'number'
49
- ? strokeWidth
50
- : Number.parseFloat(String(strokeWidth))
51
- return Number.isFinite(width) ? center - width / 2 : center
52
- }
53
-
54
- /** Build an SVG path for an arc, splitting full turns into drawable segments. */
55
- export function arcPath(startAngle: number, endAngle: number, radius: number) {
56
- const start = pointOnArc(startAngle, radius)
57
- const sweep = endAngle - startAngle
58
- const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180))
59
- const segmentSweep = sweep / segmentCount
60
- let path = `M ${start.x} ${start.y}`
61
-
62
- for (let i = 1; i <= segmentCount; i += 1) {
63
- const end = pointOnArc(startAngle + segmentSweep * i, radius)
64
- path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`
65
- }
66
-
67
- return path
68
- }
69
-
70
- /** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
71
- export function calcAngles({
72
- value,
73
- min,
74
- max,
75
- scale,
76
- startValue,
77
- angleRange,
78
- }: KnobConfig) {
79
- const p = scale.normalize(value, min, max)
80
- const s = scale.normalize(startValue, min, max)
81
-
82
- const r1 = -angleRange / 2
83
- const r2 = r1 + Math.min(p, s) * angleRange
84
- const r3 = r1 + Math.max(p, s) * angleRange
85
- const r4 = angleRange / 2
86
-
87
- return { p, r1, r2, r3, r4 }
88
- }
17
+ export type KnobContextValue = KnobConfig & KnobAngles
89
18
 
90
19
  const KnobContext = /* @__PURE__ */ createContext<KnobContextValue | null>(null)
91
20
 
@@ -11,10 +11,17 @@ import {
11
11
 
12
12
  import {
13
13
  applyDelta,
14
+ arrowKeyDirection,
14
15
  type AxisOptions,
16
+ cssLength,
17
+ DEFAULT_DRAG_SENSITIVITY,
18
+ DEFAULT_KEYBOARD_OPTIONS,
19
+ DEFAULT_WHEEL_OPTIONS,
15
20
  type InputEventOption,
21
+ knobAngles,
16
22
  type ModifierValue,
17
23
  selectModifier,
24
+ wheelDirection,
18
25
  type XY,
19
26
  } from '@tremolo-ui/dom'
20
27
  import { linearScale, type Scale, type ValueRange } from '@tremolo-ui/functions'
@@ -23,15 +30,9 @@ import { useComposedRefs } from '../../compose-refs'
23
30
  import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
24
31
  import { useDragValue } from '../../hooks/useDragValue'
25
32
  import { useWheel } from '../../hooks/useWheel'
26
- import {
27
- DEFAULT_DRAG_SENSITIVITY,
28
- DEFAULT_KEYBOARD_OPTIONS,
29
- DEFAULT_WHEEL_OPTIONS,
30
- } from '../../input-event'
31
- import { cssLength } from '../_util/css-length'
32
33
 
33
34
  import { ActiveLine } from './ActiveLine'
34
- import { calcAngles, KnobProvider } from './context'
35
+ import { KnobProvider } from './context'
35
36
  import { InactiveLine } from './InactiveLine'
36
37
  import { SVGRoot } from './SVGRoot'
37
38
  import { Thumb } from './Thumb'
@@ -265,12 +266,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
265
266
  const handleKeyDown = useCallback(
266
267
  (event: React.KeyboardEvent<HTMLOrSVGElement>) => {
267
268
  if (!keyboard || !onChange || inactive) return
268
- const key = event.key
269
- if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
270
- event.preventDefault()
271
- const direction = key === 'ArrowRight' || key === 'ArrowUp' ? 1 : -1
272
- onChange(applyDelta(value, direction, keyboard, range, event))
273
- }
269
+ const direction = arrowKeyDirection(event.key)
270
+ if (direction === null) return
271
+ event.preventDefault()
272
+ onChange(applyDelta(value, direction, keyboard, range, event))
274
273
  },
275
274
  [keyboard, onChange, inactive, value, range],
276
275
  )
@@ -304,9 +303,13 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
304
303
 
305
304
  const wheelRefCallback = useWheel<HTMLElement>((event) => {
306
305
  if (!wheel || inactive) return
306
+ // A notch the knob does not read — a sideways scroll — is left to the
307
+ // page rather than swallowed.
308
+ const direction = wheelDirection(event)
309
+ if (direction === null) return
307
310
  event.preventDefault()
308
- if (!onChange || event.deltaY === 0) return
309
- onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range, event))
311
+ if (!onChange) return
312
+ onChange(applyDelta(value, direction, wheel, range, event))
310
313
  }, WHEEL_OPTIONS)
311
314
 
312
315
  // Composed once, so React attaches the refs a single time instead of
@@ -319,7 +322,7 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
319
322
 
320
323
  const context = useMemo(() => {
321
324
  const config = { value, min, max, step, scale, startValue, angleRange }
322
- return { ...config, ...calcAngles(config) }
325
+ return { ...config, ...knobAngles(config) }
323
326
  }, [value, min, max, step, scale, startValue, angleRange])
324
327
 
325
328
  useImperativeHandle(forwardedRef, () => {
@@ -7,6 +7,12 @@ import {
7
7
  useState,
8
8
  } from 'react'
9
9
 
10
+ import {
11
+ caretAtDecimalOffset,
12
+ caretDecimalOffset,
13
+ numberSpan,
14
+ } from '@tremolo-ui/dom'
15
+
10
16
  import { useComposedRefs } from '../../compose-refs'
11
17
 
12
18
  import { useNumberInputContext } from './context'
@@ -17,25 +23,6 @@ type Props = Omit<
17
23
  'type' | 'value' | 'defaultValue'
18
24
  >
19
25
 
20
- /** The leading number of the displayed text, whatever the format put around it. */
21
- const NUMBER_PREFIX = /^\s*-?[\d.,]*/
22
-
23
- const numberEnd = (text: string) =>
24
- text.match(NUMBER_PREFIX)?.[0].length ?? text.length
25
-
26
- /**
27
- * The index the caret is measured against: the decimal point, or where one
28
- * would go if the number has none.
29
- *
30
- * Measuring from an end instead would slide the caret across a digit whenever
31
- * the number changed length — `9.9` to `10.0` gains a character in front, `10`
32
- * to `9` loses one — which is exactly what stepping does.
33
- */
34
- const decimalAnchor = (text: string) => {
35
- const dot = text.indexOf('.')
36
- return dot === -1 ? numberEnd(text) : dot
37
- }
38
-
39
26
  /**
40
27
  * The text field of a `NumberInput`, and the only place the value can be typed.
41
28
  *
@@ -47,7 +34,7 @@ const decimalAnchor = (text: string) => {
47
34
  */
48
35
  export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
49
36
  function InputField(
50
- { className, style, onFocus, onBlur, onKeyDown, ...props },
37
+ { className, style, onChange, onFocus, onBlur, onKeyDown, ...props },
51
38
  forwardedRef,
52
39
  ) {
53
40
  const {
@@ -87,10 +74,8 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
87
74
  if (selectOnFocus === 'all') {
88
75
  input.setSelectionRange(0, input.value.length)
89
76
  } else {
90
- input.setSelectionRange(
91
- 0,
92
- input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
93
- )
77
+ const span = numberSpan(input.value)
78
+ if (span) input.setSelectionRange(span.start, span.end)
94
79
  }
95
80
  // Deliberately not re-run as the text changes: that would drag the
96
81
  // selection back over what the user is typing.
@@ -114,8 +99,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
114
99
  // caret has not moved either, since the key press was prevented.
115
100
  if (!input || input.value === pending.from) return
116
101
 
117
- const place = decimalAnchor(input.value) + pending.offset
118
- const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
102
+ const caret = caretAtDecimalOffset(input.value, pending.offset)
119
103
  input.setSelectionRange(caret, caret)
120
104
  })
121
105
 
@@ -154,6 +138,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
154
138
  // stepped one no longer means anything.
155
139
  caretAfterStep.current = null
156
140
  setDraft(event.currentTarget.value)
141
+ onChange?.(event)
157
142
  }}
158
143
  onFocus={(event) => {
159
144
  setFocused(true)
@@ -185,7 +170,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
185
170
  const input = event.currentTarget
186
171
  if (keepCaretOnStep && input.selectionStart !== null) {
187
172
  caretAfterStep.current = {
188
- offset: input.selectionStart - decimalAnchor(input.value),
173
+ offset: caretDecimalOffset(input.value, input.selectionStart),
189
174
  from: input.value,
190
175
  }
191
176
  }
@@ -1,14 +1,15 @@
1
- import { ComponentPropsWithoutRef, forwardRef, useMemo, useRef } from 'react'
2
-
3
1
  import {
4
- applyDelta,
5
- type InputEventOption,
6
- mapModifier,
7
- selectModifier,
8
- } from '@tremolo-ui/dom'
2
+ ComponentPropsWithoutRef,
3
+ forwardRef,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
9
+
10
+ import { createStepperDrag, type StepperDragInstance } from '@tremolo-ui/dom'
9
11
 
10
12
  import { useComposedRefs } from '../../compose-refs'
11
- import { useDrag } from '../../hooks/useDrag'
12
13
 
13
14
  import { StepperProvider, useNumberInputContext } from './context'
14
15
 
@@ -16,7 +17,8 @@ type Props = ComponentPropsWithoutRef<'div'>
16
17
 
17
18
  /**
18
19
  * The area the steppers sit in, and a drag handle in its own right: dragging it
19
- * up and down moves the value one `step` every `drag` pixels.
20
+ * up and down moves the value one `step` every `drag` pixels. The counting is
21
+ * `createStepperDrag` in the core.
20
22
  *
21
23
  * The drag lives here rather than on `InputField` because `createDrag` turns
22
24
  * off text selection on whatever element it is attached to.
@@ -25,7 +27,6 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
25
27
  function Stepper({ className, style, children, ...props }, forwardedRef) {
26
28
  const {
27
29
  value,
28
- step,
29
30
  disabled,
30
31
  readonly,
31
32
  drag,
@@ -34,100 +35,51 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
34
35
  rawRange,
35
36
  changeValue,
36
37
  } = useNumberInputContext()
37
- const inactive = disabled || readonly
38
-
39
- /**
40
- * The sensitivity as an amount per `drag` pixels, so that the drag goes
41
- * through the same `applyDelta` the wheel and the arrow keys do. Carrying
42
- * the modifier map over rather than resolving it here is what keeps `step`
43
- * out of the pipeline for a modifier entry — naming one is a request to move
44
- * off the grid.
45
- */
46
- const dragOptions = useMemo(
47
- () =>
48
- mapModifier(dragSensitivity, (factor): InputEventOption => [
49
- 'raw',
50
- step * factor,
51
- ]),
52
- [dragSensitivity, step],
53
- )
38
+ // Only attached while it can do something: `createDrag` puts
39
+ // `touch-action: none` on the element, and a stepper that cannot be
40
+ // dragged has no reason to stop the page scrolling under a finger.
41
+ const enabled = drag !== null && !disabled && !readonly
54
42
 
55
- /**
56
- * Set once the drag has actually moved the value, so that the press-and-hold
57
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
58
- * because it is read from inside the repeat rather than rendered.
59
- */
60
- const draggingRef = useRef(false)
61
- /**
62
- * Where the drag started, taken on the first move rather than on pointer
63
- * down: the steppers act on pointer down, so by then the value may already
64
- * have been nudged once, and the drag should carry on from there.
65
- */
66
- const originRef = useRef<{ y: number; value: number } | null>(null)
67
- /**
68
- * Which modifier the drag is currently counting at, and where the previous
69
- * event was — a key produces no pointer event of its own, so a change is
70
- * only seen on the next move and has to be dated back to the one before it.
71
- */
72
- const factorRef = useRef<number>(1)
73
- const previousYRef = useRef(0)
43
+ // See useDrag for why the node is held in state rather than a ref.
44
+ const [node, setNode] = useState<HTMLDivElement | null>(null)
45
+ const instanceRef = useRef<StepperDragInstance | null>(null)
46
+ const latest = useRef({ value, changeValue })
47
+ const options = {
48
+ range: rawRange,
49
+ pixels: drag ?? 1,
50
+ sensitivity: dragSensitivity,
51
+ pointerLock,
52
+ }
53
+ const optionsRef = useRef(options)
74
54
 
75
- const dragRefCallback = useDrag<HTMLDivElement>({
76
- threshold: 1,
77
- cursor: inactive ? undefined : 'ns-resize',
78
- pointerLock: inactive ? false : pointerLock,
79
- onDragStart: (state) => {
80
- originRef.current = null
81
- draggingRef.current = false
82
- factorRef.current = selectModifier(dragSensitivity, state.event).value
83
- },
84
- onDrag: (_x, y, _dx, _dy, state) => {
85
- if (inactive || drag === null) return
86
- if (!originRef.current) {
87
- originRef.current = { y, value }
88
- previousYRef.current = y
89
- return
90
- }
55
+ // Runs after every render.
56
+ useEffect(() => {
57
+ latest.current = { value, changeValue }
58
+ optionsRef.current = options
59
+ instanceRef.current?.update(options)
60
+ })
91
61
 
92
- // Pressing or releasing the key mid-drag must not move the value, so the
93
- // travel so far is folded into the origin and measuring starts again
94
- // from the previous event: that event's own distance belongs to the new
95
- // sensitivity.
96
- const factor = selectModifier(dragSensitivity, state.event).value
97
- if (factor !== factorRef.current) {
98
- originRef.current = { y: previousYRef.current, value }
99
- factorRef.current = factor
100
- }
101
- previousYRef.current = y
62
+ useEffect(() => {
63
+ if (!node || !enabled) return
64
+ const instance = createStepperDrag(node, {
65
+ ...optionsRef.current,
66
+ getValue: () => latest.current.value,
67
+ onChange: (next) => latest.current.changeValue(next),
68
+ })
69
+ instanceRef.current = instance
70
+ return () => {
71
+ instanceRef.current = null
72
+ instance.destroy()
73
+ }
74
+ }, [node, enabled])
102
75
 
103
- // Dragging up raises the value, as on a knob.
104
- const steps = Math.round(-(y - originRef.current.y) / drag)
105
- if (steps === 0) return
106
- draggingRef.current = true
107
- // The same pipeline the wheel and the arrow keys use. Counting from where
108
- // the drag started keeps it from accumulating a rounding error.
109
- changeValue(
110
- applyDelta(
111
- originRef.current.value,
112
- steps,
113
- dragOptions,
114
- rawRange,
115
- state.event,
116
- ),
117
- )
118
- },
119
- onDragEnd: () => {
120
- draggingRef.current = false
121
- },
122
- })
76
+ const composedRef = useComposedRefs<HTMLDivElement>(forwardedRef, setNode)
123
77
 
124
- const composedRef = useComposedRefs<HTMLDivElement>(
125
- forwardedRef,
126
- drag !== null && !inactive ? dragRefCallback : undefined,
78
+ const context = useMemo(
79
+ () => ({ moved: () => instanceRef.current?.moved() ?? false }),
80
+ [],
127
81
  )
128
82
 
129
- const context = useMemo(() => ({ draggingRef }), [])
130
-
131
83
  return (
132
84
  <StepperProvider value={context}>
133
85
  <div ref={composedRef} className={className} style={style} {...props}>
@@ -41,13 +41,12 @@ export function StepperButton({
41
41
  useNumberInputContext()
42
42
  const stepper = useStepperContext()
43
43
 
44
- // Not memoized: the callback reads `draggingRef.current`, which the compiler
45
- // cannot line up with a manual dependency list. `useLongPress` only ever
46
- // calls it through a ref, so a fresh identity per render costs nothing.
44
+ // Not memoized: `useLongPress` only ever calls it through a ref, so a fresh
45
+ // identity per render costs nothing.
47
46
  const press = useLongPress(() => {
48
47
  // Once the pointer has actually travelled, the drag on `Stepper` owns the
49
48
  // value; repeating on top of it would move it twice.
50
- if (stepper?.draggingRef.current) return
49
+ if (stepper?.moved()) return
51
50
  nudge(direction, ['raw', step])
52
51
  })
53
52
 
@@ -103,7 +103,8 @@ export function useNumberInputContext<T>(
103
103
 
104
104
  /** Set by `Stepper` once a drag has actually moved, so the steppers stand down. */
105
105
  export type StepperContextValue = {
106
- draggingRef: RefObject<boolean>
106
+ /** Whether the drag in progress has moved the value. */
107
+ moved: () => boolean
107
108
  }
108
109
 
109
110
  const StepperContext =