@tremolo-ui/react 0.5.0 → 0.6.0

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