@tremolo-ui/react 0.5.0 → 0.7.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.
- package/dist/compose-refs.cjs +72 -0
- package/dist/compose-refs.cjs.map +1 -0
- package/dist/compose-refs.d.cts +43 -0
- package/dist/compose-refs.d.cts.map +1 -0
- package/dist/compose-refs.d.ts +43 -0
- package/dist/compose-refs.d.ts.map +1 -0
- package/dist/compose-refs.js +70 -0
- package/dist/compose-refs.js.map +1 -0
- package/dist/index.cjs +1669 -541
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1571 -486
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1571 -486
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1640 -538
- package/dist/index.js.map +1 -1
- package/package.json +29 -27
- package/src/components/AnimationCanvas/index.tsx +115 -76
- package/src/components/DropZone/index.tsx +100 -0
- package/src/components/FileInput/Trigger.tsx +39 -0
- package/src/components/FileInput/context.tsx +24 -0
- package/src/components/FileInput/index.tsx +144 -0
- package/src/components/Knob/ActiveLine.tsx +7 -6
- package/src/components/Knob/InactiveLine.tsx +9 -10
- package/src/components/Knob/SVGRoot.tsx +5 -3
- package/src/components/Knob/Thumb.tsx +33 -11
- package/src/components/Knob/context.tsx +17 -1
- package/src/components/Knob/index.tsx +156 -60
- package/src/components/NumberInput/DecrementStepper.tsx +3 -10
- package/src/components/NumberInput/IncrementStepper.tsx +3 -10
- package/src/components/NumberInput/InputField.tsx +185 -108
- package/src/components/NumberInput/Stepper.tsx +120 -84
- package/src/components/NumberInput/StepperButton.tsx +100 -0
- package/src/components/NumberInput/context.tsx +34 -8
- package/src/components/NumberInput/index.tsx +270 -82
- package/src/components/Piano/index.tsx +363 -200
- package/src/components/PointsEditor/Background.tsx +13 -10
- package/src/components/PointsEditor/Container.tsx +83 -29
- package/src/components/PointsEditor/Point.tsx +241 -79
- package/src/components/PointsEditor/SelectionBox.tsx +48 -0
- package/src/components/PointsEditor/context.tsx +78 -10
- package/src/components/PointsEditor/index.tsx +440 -41
- package/src/components/Slider/Marks.tsx +48 -19
- package/src/components/Slider/MarksOption.tsx +64 -45
- package/src/components/Slider/Thumb.tsx +107 -55
- package/src/components/Slider/Track.tsx +66 -62
- package/src/components/Slider/context.tsx +4 -2
- package/src/components/Slider/index.tsx +136 -77
- package/src/components/Slider/type.ts +29 -9
- package/src/components/XYPad/Area.tsx +46 -41
- package/src/components/XYPad/Thumb.tsx +131 -64
- package/src/components/XYPad/context.tsx +4 -1
- package/src/components/XYPad/index.tsx +183 -53
- package/src/components/_util/Placement.tsx +68 -0
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
- package/src/components/_util/css-length.ts +13 -0
- package/src/components/_util/decimal-digits.ts +23 -0
- package/src/components/_util/visually-hidden.ts +25 -0
- package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
- package/src/css-variables.ts +23 -0
- package/src/hooks/_internal/useCallbackRef.ts +3 -1
- package/src/hooks/_internal/useCheckSteps.ts +169 -0
- package/src/hooks/useAnimationFrame.ts +17 -7
- package/src/hooks/useDrag.ts +51 -12
- package/src/hooks/useDragValue.ts +71 -11
- package/src/hooks/useDropZone.ts +78 -0
- package/src/hooks/useEventListener.ts +10 -6
- package/src/hooks/useLongPress.ts +42 -8
- package/src/hooks/useMIDIAccess.ts +82 -11
- package/src/hooks/useMIDIInput.ts +45 -13
- package/src/hooks/useMIDIMessage.ts +23 -5
- package/src/hooks/useWheel.ts +4 -1
- package/src/index.ts +45 -25
- package/src/input-event.ts +32 -0
- package/dist/index.css +0 -501
- package/src/Globals.d.ts +0 -3
- package/src/components/Knob/index.css +0 -81
- package/src/components/NumberInput/index.css +0 -121
- package/src/components/NumberInput/stepperButton.tsx +0 -90
- package/src/components/Piano/index.css +0 -90
- package/src/components/PointsEditor/index.css +0 -35
- package/src/components/Slider/index.css +0 -125
- package/src/components/XYPad/index.css +0 -60
- package/src/components/_util/index.ts +0 -28
- package/src/styles/global.css +0 -19
- /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
|
@@ -1,123 +1,200 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ComponentPropsWithoutRef,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react'
|
|
3
9
|
|
|
4
|
-
import { useComposedRefs } from '
|
|
10
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
5
11
|
|
|
6
12
|
import { useNumberInputContext } from './context'
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
}
|
|
14
|
+
// The value belongs to `NumberInput.Root`, and the field is always text.
|
|
15
|
+
type Props = Omit<
|
|
16
|
+
ComponentPropsWithoutRef<'input'>,
|
|
17
|
+
'type' | 'value' | 'defaultValue'
|
|
18
|
+
>
|
|
25
19
|
|
|
26
20
|
/** The leading number of the displayed text, whatever the format put around it. */
|
|
27
21
|
const NUMBER_PREFIX = /^\s*-?[\d.,]*/
|
|
28
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
|
+
|
|
29
39
|
/**
|
|
30
40
|
* The text field of a `NumberInput`, and the only place the value can be typed.
|
|
31
41
|
*
|
|
32
42
|
* While the user types, their own text stands rather than `format(value)`, so
|
|
33
43
|
* that a half-finished entry is not rewritten under the caret.
|
|
44
|
+
*
|
|
45
|
+
* How it behaves on focus, Enter and the arrow keys is set on `Root`
|
|
46
|
+
* (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
|
|
34
47
|
*/
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
48
|
+
export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
49
|
+
function InputField(
|
|
50
|
+
{ className, style, onFocus, onBlur, onKeyDown, ...props },
|
|
51
|
+
forwardedRef,
|
|
52
|
+
) {
|
|
53
|
+
const {
|
|
54
|
+
value,
|
|
55
|
+
min,
|
|
56
|
+
max,
|
|
57
|
+
step,
|
|
58
|
+
disabled,
|
|
59
|
+
readonly,
|
|
60
|
+
keyboard,
|
|
61
|
+
selectOnFocus,
|
|
62
|
+
unformatOnFocus,
|
|
63
|
+
keepCaretOnStep,
|
|
64
|
+
blurOnEnter,
|
|
65
|
+
text,
|
|
66
|
+
editing,
|
|
67
|
+
outOfRange,
|
|
68
|
+
setDraft,
|
|
69
|
+
commitDraft,
|
|
70
|
+
nudge,
|
|
71
|
+
inputRef,
|
|
72
|
+
} = useNumberInputContext()
|
|
73
|
+
|
|
74
|
+
const [focused, setFocused] = useState(false)
|
|
75
|
+
|
|
76
|
+
// Only until the first keystroke: from then on the draft is the user's own
|
|
77
|
+
// text and stands on its own, formatted or not.
|
|
78
|
+
const shown = unformatOnFocus && focused && !editing ? String(value) : text
|
|
79
|
+
|
|
80
|
+
// Selecting has to wait for that swap. Called from the focus handler it
|
|
81
|
+
// would run against the formatted text still in the input and cover the
|
|
82
|
+
// wrong characters.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!focused || selectOnFocus === 'none') return
|
|
85
|
+
const input = inputRef.current
|
|
86
|
+
if (!input) return
|
|
87
|
+
if (selectOnFocus === 'all') {
|
|
88
|
+
input.setSelectionRange(0, input.value.length)
|
|
89
|
+
} else {
|
|
90
|
+
input.setSelectionRange(
|
|
91
|
+
0,
|
|
92
|
+
input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
// Deliberately not re-run as the text changes: that would drag the
|
|
96
|
+
// selection back over what the user is typing.
|
|
97
|
+
}, [focused, selectOnFocus, inputRef])
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Where to put the caret once the stepped value has been rendered, and the
|
|
101
|
+
* text it was measured against.
|
|
102
|
+
*/
|
|
103
|
+
const caretAfterStep = useRef<{ offset: number; from: string } | null>(null)
|
|
104
|
+
|
|
105
|
+
// A layout effect rather than a plain one: the caret is moved before the
|
|
106
|
+
// browser paints, so it is never seen at the end of the text first.
|
|
107
|
+
useLayoutEffect(() => {
|
|
108
|
+
const pending = caretAfterStep.current
|
|
109
|
+
if (!pending) return
|
|
110
|
+
caretAfterStep.current = null
|
|
111
|
+
|
|
112
|
+
const input = inputRef.current
|
|
113
|
+
// The step may have been clamped away, leaving the text as it was. The
|
|
114
|
+
// caret has not moved either, since the key press was prevented.
|
|
115
|
+
if (!input || input.value === pending.from) return
|
|
116
|
+
|
|
117
|
+
const place = decimalAnchor(input.value) + pending.offset
|
|
118
|
+
const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
|
|
119
|
+
input.setSelectionRange(caret, caret)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const composedRef = useComposedRefs<HTMLInputElement>(
|
|
123
|
+
forwardedRef,
|
|
124
|
+
inputRef,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<input
|
|
129
|
+
ref={composedRef}
|
|
130
|
+
className={className}
|
|
131
|
+
// Not type="number": that brings native spinners and a value the browser
|
|
132
|
+
// parses itself, neither of which survives a unit suffix.
|
|
133
|
+
type="text"
|
|
134
|
+
inputMode="decimal"
|
|
135
|
+
role="spinbutton"
|
|
136
|
+
value={shown}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
readOnly={readonly}
|
|
139
|
+
aria-disabled={disabled}
|
|
140
|
+
aria-readonly={readonly}
|
|
141
|
+
data-disabled={disabled ? '' : undefined}
|
|
142
|
+
data-readonly={readonly ? '' : undefined}
|
|
143
|
+
aria-valuenow={value}
|
|
144
|
+
aria-valuemin={min}
|
|
145
|
+
aria-valuemax={max}
|
|
146
|
+
// The formatted text, even while the field shows the plain number: it is
|
|
147
|
+
// the one that says what the value means.
|
|
148
|
+
aria-valuetext={text}
|
|
149
|
+
step={step}
|
|
150
|
+
data-out-of-range={outOfRange ? '' : undefined}
|
|
151
|
+
style={style}
|
|
152
|
+
onChange={(event) => {
|
|
153
|
+
// The text is the user's own now, so a caret measured against the
|
|
154
|
+
// stepped one no longer means anything.
|
|
155
|
+
caretAfterStep.current = null
|
|
156
|
+
setDraft(event.currentTarget.value)
|
|
157
|
+
}}
|
|
158
|
+
onFocus={(event) => {
|
|
159
|
+
setFocused(true)
|
|
160
|
+
onFocus?.(event)
|
|
161
|
+
}}
|
|
162
|
+
onBlur={(event) => {
|
|
163
|
+
setFocused(false)
|
|
164
|
+
// Nothing was typed, so there is no draft and this returns at once —
|
|
165
|
+
// taking focus and leaving again never commits anything.
|
|
108
166
|
commitDraft()
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
(key
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
167
|
+
onBlur?.(event)
|
|
168
|
+
}}
|
|
169
|
+
onKeyDown={(event) => {
|
|
170
|
+
const key = event.key
|
|
171
|
+
if (key === 'Enter') {
|
|
172
|
+
commitDraft()
|
|
173
|
+
if (blurOnEnter) event.currentTarget.blur()
|
|
174
|
+
} else if (
|
|
175
|
+
keyboard &&
|
|
176
|
+
!disabled &&
|
|
177
|
+
!readonly &&
|
|
178
|
+
(key === 'ArrowUp' || key === 'ArrowDown') &&
|
|
179
|
+
// Arrow keys pick a candidate while an IME is converting. Stepping
|
|
180
|
+
// the value there would fight the conversion, and moving the caret
|
|
181
|
+
// would break it outright.
|
|
182
|
+
!event.nativeEvent.isComposing
|
|
183
|
+
) {
|
|
184
|
+
event.preventDefault()
|
|
185
|
+
const input = event.currentTarget
|
|
186
|
+
if (keepCaretOnStep && input.selectionStart !== null) {
|
|
187
|
+
caretAfterStep.current = {
|
|
188
|
+
offset: input.selectionStart - decimalAnchor(input.value),
|
|
189
|
+
from: input.value,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
nudge(key === 'ArrowUp' ? 1 : -1, keyboard, event)
|
|
193
|
+
}
|
|
194
|
+
onKeyDown?.(event)
|
|
195
|
+
}}
|
|
196
|
+
{...props}
|
|
197
|
+
/>
|
|
198
|
+
)
|
|
199
|
+
},
|
|
200
|
+
)
|
|
@@ -1,27 +1,18 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
ComponentPropsWithoutRef,
|
|
4
|
-
CSSProperties,
|
|
5
|
-
ReactNode,
|
|
6
|
-
Ref,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
} from 'react'
|
|
1
|
+
import { ComponentPropsWithoutRef, forwardRef, useMemo, useRef } from 'react'
|
|
10
2
|
|
|
11
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyDelta,
|
|
5
|
+
type InputEventOption,
|
|
6
|
+
mapModifier,
|
|
7
|
+
selectModifier,
|
|
8
|
+
} from '@tremolo-ui/dom'
|
|
12
9
|
|
|
10
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
13
11
|
import { useDrag } from '../../hooks/useDrag'
|
|
14
|
-
import { useComposedRefs } from '../_util/composeRefs'
|
|
15
12
|
|
|
16
13
|
import { StepperProvider, useNumberInputContext } from './context'
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
className?: string
|
|
20
|
-
style?: CSSProperties
|
|
21
|
-
/** `<NumberInput.IncrementStepper />` and `<NumberInput.DecrementStepper />` go here. */
|
|
22
|
-
children?: ReactNode
|
|
23
|
-
ref?: Ref<HTMLDivElement>
|
|
24
|
-
}
|
|
15
|
+
type Props = ComponentPropsWithoutRef<'div'>
|
|
25
16
|
|
|
26
17
|
/**
|
|
27
18
|
* The area the steppers sit in, and a drag handle in its own right: dragging it
|
|
@@ -30,74 +21,119 @@ export interface StepperProps {
|
|
|
30
21
|
* The drag lives here rather than on `InputField` because `createDrag` turns
|
|
31
22
|
* off text selection on whatever element it is attached to.
|
|
32
23
|
*/
|
|
33
|
-
export
|
|
34
|
-
className,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
25
|
+
function Stepper({ className, style, children, ...props }, forwardedRef) {
|
|
26
|
+
const {
|
|
27
|
+
value,
|
|
28
|
+
step,
|
|
29
|
+
disabled,
|
|
30
|
+
readonly,
|
|
31
|
+
drag,
|
|
32
|
+
dragSensitivity,
|
|
33
|
+
pointerLock,
|
|
34
|
+
rawRange,
|
|
35
|
+
changeValue,
|
|
36
|
+
} = 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
|
+
)
|
|
54
|
+
|
|
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)
|
|
74
|
+
|
|
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
|
+
}
|
|
42
91
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
const originRef = useRef<{ y: number; value: number } | null>(null)
|
|
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
|
|
55
102
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
applyDelta(originRef.current.value, steps, ['raw', step], range),
|
|
77
|
-
)
|
|
78
|
-
},
|
|
79
|
-
onDragEnd: () => {
|
|
80
|
-
draggingRef.current = false
|
|
81
|
-
},
|
|
82
|
-
})
|
|
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
|
+
})
|
|
83
123
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
124
|
+
const composedRef = useComposedRefs<HTMLDivElement>(
|
|
125
|
+
forwardedRef,
|
|
126
|
+
drag !== null && !inactive ? dragRefCallback : undefined,
|
|
127
|
+
)
|
|
88
128
|
|
|
89
|
-
|
|
129
|
+
const context = useMemo(() => ({ draggingRef }), [])
|
|
90
130
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
</div>
|
|
101
|
-
</StepperProvider>
|
|
102
|
-
)
|
|
103
|
-
}
|
|
131
|
+
return (
|
|
132
|
+
<StepperProvider value={context}>
|
|
133
|
+
<div ref={composedRef} className={className} style={style} {...props}>
|
|
134
|
+
{children}
|
|
135
|
+
</div>
|
|
136
|
+
</StepperProvider>
|
|
137
|
+
)
|
|
138
|
+
},
|
|
139
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, ReactNode, CSSProperties } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useLongPress } from '../../hooks/useLongPress'
|
|
4
|
+
|
|
5
|
+
import { useNumberInputContext, useStepperContext } from './context'
|
|
6
|
+
|
|
7
|
+
import type { CSSVariables } from '../../css-variables'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `--stepper-icon-size` is read by the default arrow inside, and set on the
|
|
11
|
+
* button so that it inherits there.
|
|
12
|
+
*/
|
|
13
|
+
export type StepperButtonProps = Omit<
|
|
14
|
+
ComponentPropsWithoutRef<'div'>,
|
|
15
|
+
'style'
|
|
16
|
+
> & {
|
|
17
|
+
style?: CSSProperties & CSSVariables<'stepper-icon-size'>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Props = StepperButtonProps
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
|
|
24
|
+
* in which way they move the value and which arrow they draw.
|
|
25
|
+
*/
|
|
26
|
+
export function StepperButton({
|
|
27
|
+
direction,
|
|
28
|
+
variant,
|
|
29
|
+
icon,
|
|
30
|
+
className,
|
|
31
|
+
style,
|
|
32
|
+
children,
|
|
33
|
+
onPointerDown,
|
|
34
|
+
...props
|
|
35
|
+
}: Props & {
|
|
36
|
+
direction: 1 | -1
|
|
37
|
+
variant: 'increment' | 'decrement'
|
|
38
|
+
icon: ReactNode
|
|
39
|
+
}) {
|
|
40
|
+
const { step, disabled, readonly, atMin, atMax, nudge } =
|
|
41
|
+
useNumberInputContext()
|
|
42
|
+
const stepper = useStepperContext()
|
|
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.
|
|
47
|
+
const press = useLongPress(() => {
|
|
48
|
+
// Once the pointer has actually travelled, the drag on `Stepper` owns the
|
|
49
|
+
// value; repeating on top of it would move it twice.
|
|
50
|
+
if (stepper?.draggingRef.current) return
|
|
51
|
+
nudge(direction, ['raw', step])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={className}
|
|
57
|
+
role="button"
|
|
58
|
+
tabIndex={-1}
|
|
59
|
+
// A bare arrow has no accessible name of its own. Overridable, since a
|
|
60
|
+
// caller may need it in their own language.
|
|
61
|
+
aria-label={direction > 0 ? 'Increment' : 'Decrement'}
|
|
62
|
+
aria-disabled={disabled || (direction > 0 ? atMax : atMin)}
|
|
63
|
+
// `role="button"` does not take aria-readonly, so the state reaches the
|
|
64
|
+
// styles through the data attribute alone.
|
|
65
|
+
data-disabled={
|
|
66
|
+
disabled || (direction > 0 ? atMax : atMin) ? '' : undefined
|
|
67
|
+
}
|
|
68
|
+
data-readonly={readonly ? '' : undefined}
|
|
69
|
+
style={style}
|
|
70
|
+
onPointerDown={(event) => {
|
|
71
|
+
if (!disabled && !readonly) press(event)
|
|
72
|
+
onPointerDown?.(event)
|
|
73
|
+
}}
|
|
74
|
+
{...props}
|
|
75
|
+
>
|
|
76
|
+
{children ?? icon}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function StepperArrow({ up }: { up: boolean }) {
|
|
82
|
+
return (
|
|
83
|
+
<svg
|
|
84
|
+
// The arrow the component draws when no children are given, so it sizes
|
|
85
|
+
// itself rather than asking a stylesheet to.
|
|
86
|
+
style={{
|
|
87
|
+
width: 'var(--stepper-icon-size)',
|
|
88
|
+
height: 'var(--stepper-icon-size)',
|
|
89
|
+
}}
|
|
90
|
+
viewBox="0 0 24 24"
|
|
91
|
+
fill="none"
|
|
92
|
+
stroke="currentColor"
|
|
93
|
+
strokeWidth="2"
|
|
94
|
+
strokeLinecap="round"
|
|
95
|
+
strokeLinejoin="round"
|
|
96
|
+
>
|
|
97
|
+
<polyline points={up ? '18 15 12 9 6 15' : '6 9 12 15 18 9'} />
|
|
98
|
+
</svg>
|
|
99
|
+
)
|
|
100
|
+
}
|