@tremolo-ui/react 0.7.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.
- package/dist/index.cjs +666 -1171
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +316 -315
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +316 -315
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +643 -1123
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/components/AnimationCanvas/index.tsx +4 -18
- package/src/components/DropZone/index.tsx +2 -1
- package/src/components/FileInput/index.tsx +11 -13
- package/src/components/Knob/ActiveLine.tsx +5 -3
- package/src/components/Knob/InactiveLine.tsx +6 -4
- package/src/components/Knob/SVGRoot.tsx +3 -3
- package/src/components/Knob/Thumb.tsx +21 -20
- package/src/components/Knob/context.tsx +3 -74
- package/src/components/Knob/index.tsx +99 -52
- package/src/components/NumberInput/InputField.tsx +20 -33
- package/src/components/NumberInput/Stepper.tsx +76 -100
- package/src/components/NumberInput/StepperButton.tsx +15 -8
- package/src/components/NumberInput/context.tsx +26 -3
- package/src/components/NumberInput/index.tsx +175 -91
- package/src/components/Piano/index.tsx +54 -144
- package/src/components/PointsEditor/Container.tsx +15 -26
- package/src/components/PointsEditor/Point.tsx +94 -110
- package/src/components/PointsEditor/context.tsx +8 -60
- package/src/components/PointsEditor/index.tsx +41 -324
- package/src/components/Slider/Marks.tsx +5 -6
- package/src/components/Slider/MarksOption.tsx +9 -19
- package/src/components/Slider/Thumb.tsx +20 -11
- package/src/components/Slider/Track.tsx +16 -13
- package/src/components/Slider/context.tsx +3 -3
- package/src/components/Slider/index.tsx +110 -72
- package/src/components/XYPad/Area.tsx +27 -49
- package/src/components/XYPad/Thumb.tsx +20 -8
- package/src/components/XYPad/context.tsx +2 -4
- package/src/components/XYPad/index.tsx +102 -83
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
- package/src/hooks/_internal/useChangeGesture.ts +93 -0
- package/src/hooks/_internal/useCheckSteps.ts +9 -146
- package/src/hooks/useAnimationFrame.ts +37 -15
- package/src/hooks/useEventListener.ts +93 -22
- package/src/hooks/useLongPress.ts +35 -51
- package/src/hooks/useMIDIAccess.ts +0 -8
- package/src/hooks/useMIDIInput.ts +0 -3
- package/src/hooks/useWheel.ts +4 -4
- package/src/index.ts +11 -32
- package/src/components/Piano/keyboard-shortcuts.ts +0 -61
- package/src/components/Slider/type.ts +0 -46
- package/src/components/_util/css-length.ts +0 -13
- package/src/components/_util/decimal-digits.ts +0 -23
- package/src/components/_util/visually-hidden.ts +0 -25
- package/src/hooks/useInterval.ts +0 -27
- package/src/input-event.ts +0 -32
|
@@ -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,38 +11,39 @@ import {
|
|
|
10
11
|
} from 'react'
|
|
11
12
|
|
|
12
13
|
import {
|
|
13
|
-
applyDelta,
|
|
14
14
|
type AxisOptions,
|
|
15
|
+
type ChangeSource,
|
|
16
|
+
DEFAULT_DRAG_SENSITIVITY,
|
|
17
|
+
DEFAULT_KEYBOARD_OPTIONS,
|
|
18
|
+
DEFAULT_WHEEL_OPTIONS,
|
|
15
19
|
type InputEventOption,
|
|
20
|
+
knobAngles,
|
|
16
21
|
type ModifierValue,
|
|
17
|
-
selectModifier,
|
|
18
22
|
type XY,
|
|
19
23
|
} from '@tremolo-ui/dom'
|
|
24
|
+
import {
|
|
25
|
+
applyDelta,
|
|
26
|
+
arrowKeyDirection,
|
|
27
|
+
cssLength,
|
|
28
|
+
selectModifier,
|
|
29
|
+
wheelDirection,
|
|
30
|
+
} from '@tremolo-ui/dom/internal'
|
|
20
31
|
import { linearScale, type Scale, type ValueRange } from '@tremolo-ui/functions'
|
|
21
32
|
|
|
22
33
|
import { useComposedRefs } from '../../compose-refs'
|
|
34
|
+
import { useChangeGesture } from '../../hooks/_internal/useChangeGesture'
|
|
23
35
|
import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
|
|
24
36
|
import { useDragValue } from '../../hooks/useDragValue'
|
|
25
37
|
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
38
|
|
|
33
39
|
import { ActiveLine } from './ActiveLine'
|
|
34
|
-
import {
|
|
40
|
+
import { KnobProvider } from './context'
|
|
35
41
|
import { InactiveLine } from './InactiveLine'
|
|
36
42
|
import { SVGRoot } from './SVGRoot'
|
|
37
43
|
import { Thumb } from './Thumb'
|
|
38
44
|
|
|
39
45
|
import type { CSSVariables } from '../../css-variables'
|
|
40
46
|
|
|
41
|
-
const defaultExternalStyles: KnobProps['externalStyles'] = {
|
|
42
|
-
cursor: 'grabbing',
|
|
43
|
-
}
|
|
44
|
-
|
|
45
47
|
export interface KnobProps {
|
|
46
48
|
/** The current value. The knob shows only this, so update it from `onChange`. */
|
|
47
49
|
value: number
|
|
@@ -68,10 +70,10 @@ export interface KnobProps {
|
|
|
68
70
|
*/
|
|
69
71
|
scale?: Scale
|
|
70
72
|
/**
|
|
71
|
-
* The value a double click restores
|
|
72
|
-
* @default
|
|
73
|
+
* The value a double click restores. `null` turns the double click off.
|
|
74
|
+
* @default startValue
|
|
73
75
|
*/
|
|
74
|
-
|
|
76
|
+
resetValue?: number | null
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* Where the active arc starts. Put it at the centre of a bipolar control,
|
|
@@ -90,11 +92,9 @@ export interface KnobProps {
|
|
|
90
92
|
* The cursor to show while dragging. It is set on the dragged element, so it
|
|
91
93
|
* stays while the pointer is outside the knob.
|
|
92
94
|
*
|
|
93
|
-
* @default
|
|
95
|
+
* @default 'grabbing'
|
|
94
96
|
*/
|
|
95
|
-
|
|
96
|
-
cursor?: CSSProperties['cursor']
|
|
97
|
-
}
|
|
97
|
+
dragCursor?: CSSProperties['cursor']
|
|
98
98
|
/**
|
|
99
99
|
* How much one notch of the wheel moves the value. It only acts while the
|
|
100
100
|
* focus is inside, so that scrolling the page past the knob leaves it alone.
|
|
@@ -157,11 +157,6 @@ export interface KnobProps {
|
|
|
157
157
|
* @default { default: ['raw', 1], shift: ['raw', 0.1] }
|
|
158
158
|
*/
|
|
159
159
|
keyboard?: ModifierValue<InputEventOption> | null
|
|
160
|
-
/**
|
|
161
|
-
* Restore `defaultValue` on a double click.
|
|
162
|
-
* @default true
|
|
163
|
-
*/
|
|
164
|
-
enableDoubleClickDefault?: boolean
|
|
165
160
|
|
|
166
161
|
/**
|
|
167
162
|
* Make the knob unchangeable and remove it from the tab order.
|
|
@@ -172,7 +167,7 @@ export interface KnobProps {
|
|
|
172
167
|
* Make the knob unchangeable while leaving it focusable.
|
|
173
168
|
* The parts carry `data-readonly` while it is set.
|
|
174
169
|
*/
|
|
175
|
-
|
|
170
|
+
readOnly?: boolean
|
|
176
171
|
|
|
177
172
|
/**
|
|
178
173
|
* How far the knob turns from `min` to `max`, in degrees, centred on the
|
|
@@ -186,6 +181,25 @@ export interface KnobProps {
|
|
|
186
181
|
* click moves it.
|
|
187
182
|
*/
|
|
188
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
|
|
189
203
|
|
|
190
204
|
/**
|
|
191
205
|
* The knob renders exactly what you compose here; there is no default
|
|
@@ -203,6 +217,13 @@ export interface KnobProps {
|
|
|
203
217
|
children: ReactNode
|
|
204
218
|
|
|
205
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>
|
|
206
227
|
}
|
|
207
228
|
|
|
208
229
|
export interface KnobMethods {
|
|
@@ -218,7 +239,7 @@ type Props = KnobProps & Omit<ComponentPropsWithoutRef<'div'>, keyof KnobProps>
|
|
|
218
239
|
*/
|
|
219
240
|
const WHEEL_OPTIONS = { requireFocus: true }
|
|
220
241
|
|
|
221
|
-
export const Root = /* @__PURE__ */ forwardRef<
|
|
242
|
+
export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
222
243
|
(
|
|
223
244
|
{
|
|
224
245
|
value,
|
|
@@ -226,33 +247,35 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
226
247
|
max,
|
|
227
248
|
step = 1,
|
|
228
249
|
scale = linearScale,
|
|
229
|
-
|
|
250
|
+
resetValue,
|
|
230
251
|
startValue = min,
|
|
231
252
|
size,
|
|
232
|
-
|
|
253
|
+
dragCursor = 'grabbing',
|
|
233
254
|
wheel = DEFAULT_WHEEL_OPTIONS,
|
|
234
255
|
keyboard = DEFAULT_KEYBOARD_OPTIONS,
|
|
235
256
|
dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
|
|
236
257
|
pointerLock = false,
|
|
237
|
-
enableDoubleClickDefault = true,
|
|
238
258
|
disabled = false,
|
|
239
|
-
|
|
259
|
+
readOnly = false,
|
|
240
260
|
angleRange = 270,
|
|
241
261
|
onChange,
|
|
262
|
+
onChangeStart,
|
|
263
|
+
onChangeEnd,
|
|
264
|
+
changeEndDelay,
|
|
242
265
|
onKeyDown,
|
|
243
266
|
onPointerDown,
|
|
244
267
|
onDoubleClick,
|
|
245
268
|
className,
|
|
246
269
|
style,
|
|
247
270
|
children,
|
|
271
|
+
actionsRef,
|
|
248
272
|
...props
|
|
249
273
|
}: Props,
|
|
250
274
|
forwardedRef,
|
|
251
275
|
) => {
|
|
252
|
-
const elmRef = useRef<
|
|
276
|
+
const elmRef = useRef<HTMLDivElement>(null)
|
|
253
277
|
|
|
254
|
-
const
|
|
255
|
-
const inactive = disabled || readonly
|
|
278
|
+
const inactive = disabled || readOnly
|
|
256
279
|
|
|
257
280
|
// --- internal functions ---
|
|
258
281
|
const range: ValueRange = useMemo(
|
|
@@ -262,17 +285,29 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
262
285
|
|
|
263
286
|
useCheckSteps({ component: 'Knob', range, keyboard, wheel })
|
|
264
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
|
+
|
|
265
301
|
const handleKeyDown = useCallback(
|
|
266
302
|
(event: React.KeyboardEvent<HTMLOrSVGElement>) => {
|
|
267
303
|
if (!keyboard || !onChange || inactive) return
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
304
|
+
const direction = arrowKeyDirection(event.key)
|
|
305
|
+
if (direction === null) return
|
|
306
|
+
event.preventDefault()
|
|
307
|
+
gesture.pulse('keyboard')
|
|
308
|
+
change(applyDelta(value, direction, keyboard, range, event))
|
|
274
309
|
},
|
|
275
|
-
[keyboard, onChange, inactive, value, range],
|
|
310
|
+
[keyboard, onChange, inactive, value, range, gesture, change],
|
|
276
311
|
)
|
|
277
312
|
|
|
278
313
|
// --- hooks ---
|
|
@@ -293,25 +328,35 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
293
328
|
sensitivity: (state) =>
|
|
294
329
|
selectModifier(dragSensitivity, state.event).value,
|
|
295
330
|
threshold: 1,
|
|
296
|
-
cursor: inactive ? undefined :
|
|
331
|
+
cursor: inactive ? undefined : dragCursor,
|
|
297
332
|
pointerLock: inactive ? false : pointerLock,
|
|
298
333
|
shouldStart: () => !inactive,
|
|
299
334
|
onChange: (v) => {
|
|
300
335
|
if (inactive) return
|
|
301
|
-
|
|
336
|
+
change(v[1])
|
|
337
|
+
},
|
|
338
|
+
onDragStart: () => {
|
|
339
|
+
if (!inactive) gesture.hold('pointer')
|
|
302
340
|
},
|
|
341
|
+
onDragEnd: () => gesture.end(),
|
|
303
342
|
})
|
|
304
343
|
|
|
305
344
|
const wheelRefCallback = useWheel<HTMLElement>((event) => {
|
|
306
345
|
if (!wheel || inactive) return
|
|
346
|
+
// A notch the knob does not read — a sideways scroll — is left to the
|
|
347
|
+
// page rather than swallowed.
|
|
348
|
+
const direction = wheelDirection(event)
|
|
349
|
+
if (direction === null) return
|
|
307
350
|
event.preventDefault()
|
|
308
|
-
if (!onChange
|
|
309
|
-
|
|
351
|
+
if (!onChange) return
|
|
352
|
+
gesture.pulse('wheel')
|
|
353
|
+
change(applyDelta(value, direction, wheel, range, event))
|
|
310
354
|
}, WHEEL_OPTIONS)
|
|
311
355
|
|
|
312
356
|
// Composed once, so React attaches the refs a single time instead of
|
|
313
357
|
// detaching and re-attaching on every render.
|
|
314
|
-
const rootRefCallback = useComposedRefs<
|
|
358
|
+
const rootRefCallback = useComposedRefs<HTMLDivElement>(
|
|
359
|
+
forwardedRef,
|
|
315
360
|
elmRef,
|
|
316
361
|
dragRefCallback,
|
|
317
362
|
wheelRefCallback,
|
|
@@ -319,10 +364,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
319
364
|
|
|
320
365
|
const context = useMemo(() => {
|
|
321
366
|
const config = { value, min, max, step, scale, startValue, angleRange }
|
|
322
|
-
return { ...config, ...
|
|
367
|
+
return { ...config, ...knobAngles(config) }
|
|
323
368
|
}, [value, min, max, step, scale, startValue, angleRange])
|
|
324
369
|
|
|
325
|
-
useImperativeHandle(
|
|
370
|
+
useImperativeHandle(actionsRef, () => {
|
|
326
371
|
return {
|
|
327
372
|
focus() {
|
|
328
373
|
if (!disabled) elmRef.current?.focus()
|
|
@@ -344,9 +389,9 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
344
389
|
aria-valuemin={min}
|
|
345
390
|
aria-valuemax={max}
|
|
346
391
|
aria-disabled={disabled}
|
|
347
|
-
aria-readonly={
|
|
392
|
+
aria-readonly={readOnly}
|
|
348
393
|
data-disabled={disabled ? '' : undefined}
|
|
349
|
-
data-readonly={
|
|
394
|
+
data-readonly={readOnly ? '' : undefined}
|
|
350
395
|
data-dragging={dragging ? '' : undefined}
|
|
351
396
|
style={
|
|
352
397
|
{
|
|
@@ -356,8 +401,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
356
401
|
}
|
|
357
402
|
onPointerDown={onPointerDown}
|
|
358
403
|
onDoubleClick={(event) => {
|
|
359
|
-
if (!inactive &&
|
|
360
|
-
|
|
404
|
+
if (!inactive && resetValue !== null && onChange) {
|
|
405
|
+
gesture.instant('doubleClick', () =>
|
|
406
|
+
change(resetValue ?? startValue),
|
|
407
|
+
)
|
|
361
408
|
}
|
|
362
409
|
onDoubleClick?.(event)
|
|
363
410
|
}}
|
|
@@ -7,9 +7,15 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
} from 'react'
|
|
9
9
|
|
|
10
|
+
import {
|
|
11
|
+
caretAtDecimalOffset,
|
|
12
|
+
caretDecimalOffset,
|
|
13
|
+
numberSpan,
|
|
14
|
+
} from '@tremolo-ui/dom/internal'
|
|
15
|
+
|
|
10
16
|
import { useComposedRefs } from '../../compose-refs'
|
|
11
17
|
|
|
12
|
-
import { useNumberInputContext } from './context'
|
|
18
|
+
import { useNumberInputContext, useNumberInputGesture } from './context'
|
|
13
19
|
|
|
14
20
|
// The value belongs to `NumberInput.Root`, and the field is always text.
|
|
15
21
|
type Props = Omit<
|
|
@@ -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 {
|
|
@@ -56,7 +43,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
56
43
|
max,
|
|
57
44
|
step,
|
|
58
45
|
disabled,
|
|
59
|
-
|
|
46
|
+
readOnly,
|
|
60
47
|
keyboard,
|
|
61
48
|
selectOnFocus,
|
|
62
49
|
unformatOnFocus,
|
|
@@ -71,6 +58,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
71
58
|
inputRef,
|
|
72
59
|
} = useNumberInputContext()
|
|
73
60
|
|
|
61
|
+
const gesture = useNumberInputGesture()
|
|
74
62
|
const [focused, setFocused] = useState(false)
|
|
75
63
|
|
|
76
64
|
// Only until the first keystroke: from then on the draft is the user's own
|
|
@@ -87,10 +75,8 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
87
75
|
if (selectOnFocus === 'all') {
|
|
88
76
|
input.setSelectionRange(0, input.value.length)
|
|
89
77
|
} else {
|
|
90
|
-
input.
|
|
91
|
-
|
|
92
|
-
input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
|
|
93
|
-
)
|
|
78
|
+
const span = numberSpan(input.value)
|
|
79
|
+
if (span) input.setSelectionRange(span.start, span.end)
|
|
94
80
|
}
|
|
95
81
|
// Deliberately not re-run as the text changes: that would drag the
|
|
96
82
|
// selection back over what the user is typing.
|
|
@@ -114,8 +100,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
114
100
|
// caret has not moved either, since the key press was prevented.
|
|
115
101
|
if (!input || input.value === pending.from) return
|
|
116
102
|
|
|
117
|
-
const
|
|
118
|
-
const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
|
|
103
|
+
const caret = caretAtDecimalOffset(input.value, pending.offset)
|
|
119
104
|
input.setSelectionRange(caret, caret)
|
|
120
105
|
})
|
|
121
106
|
|
|
@@ -135,11 +120,11 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
135
120
|
role="spinbutton"
|
|
136
121
|
value={shown}
|
|
137
122
|
disabled={disabled}
|
|
138
|
-
readOnly={
|
|
123
|
+
readOnly={readOnly}
|
|
139
124
|
aria-disabled={disabled}
|
|
140
|
-
aria-readonly={
|
|
125
|
+
aria-readonly={readOnly}
|
|
141
126
|
data-disabled={disabled ? '' : undefined}
|
|
142
|
-
data-readonly={
|
|
127
|
+
data-readonly={readOnly ? '' : undefined}
|
|
143
128
|
aria-valuenow={value}
|
|
144
129
|
aria-valuemin={min}
|
|
145
130
|
aria-valuemax={max}
|
|
@@ -154,6 +139,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
154
139
|
// stepped one no longer means anything.
|
|
155
140
|
caretAfterStep.current = null
|
|
156
141
|
setDraft(event.currentTarget.value)
|
|
142
|
+
onChange?.(event)
|
|
157
143
|
}}
|
|
158
144
|
onFocus={(event) => {
|
|
159
145
|
setFocused(true)
|
|
@@ -174,7 +160,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
174
160
|
} else if (
|
|
175
161
|
keyboard &&
|
|
176
162
|
!disabled &&
|
|
177
|
-
!
|
|
163
|
+
!readOnly &&
|
|
178
164
|
(key === 'ArrowUp' || key === 'ArrowDown') &&
|
|
179
165
|
// Arrow keys pick a candidate while an IME is converting. Stepping
|
|
180
166
|
// the value there would fight the conversion, and moving the caret
|
|
@@ -185,10 +171,11 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
185
171
|
const input = event.currentTarget
|
|
186
172
|
if (keepCaretOnStep && input.selectionStart !== null) {
|
|
187
173
|
caretAfterStep.current = {
|
|
188
|
-
offset: input.
|
|
174
|
+
offset: caretDecimalOffset(input.value, input.selectionStart),
|
|
189
175
|
from: input.value,
|
|
190
176
|
}
|
|
191
177
|
}
|
|
178
|
+
gesture?.key()
|
|
192
179
|
nudge(key === 'ArrowUp' ? 1 : -1, keyboard, event)
|
|
193
180
|
}
|
|
194
181
|
onKeyDown?.(event)
|
|
@@ -1,136 +1,112 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ComponentPropsWithoutRef,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react'
|
|
2
9
|
|
|
3
10
|
import {
|
|
4
|
-
|
|
5
|
-
type
|
|
6
|
-
|
|
7
|
-
selectModifier,
|
|
8
|
-
} from '@tremolo-ui/dom'
|
|
11
|
+
createStepperDrag,
|
|
12
|
+
type StepperDragInstance,
|
|
13
|
+
} from '@tremolo-ui/dom/internal'
|
|
9
14
|
|
|
10
15
|
import { useComposedRefs } from '../../compose-refs'
|
|
11
|
-
import { useDrag } from '../../hooks/useDrag'
|
|
12
16
|
|
|
13
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
StepperProvider,
|
|
19
|
+
useNumberInputContext,
|
|
20
|
+
useNumberInputGesture,
|
|
21
|
+
} from './context'
|
|
14
22
|
|
|
15
23
|
type Props = ComponentPropsWithoutRef<'div'>
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* 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.
|
|
27
|
+
* up and down moves the value one `step` every `drag` pixels. The counting is
|
|
28
|
+
* `createStepperDrag` in the core.
|
|
20
29
|
*
|
|
21
30
|
* The drag lives here rather than on `InputField` because `createDrag` turns
|
|
22
31
|
* off text selection on whatever element it is attached to.
|
|
23
32
|
*/
|
|
24
33
|
export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
25
|
-
function Stepper(
|
|
34
|
+
function Stepper(
|
|
35
|
+
{ className, style, children, onPointerDown, ...props },
|
|
36
|
+
forwardedRef,
|
|
37
|
+
) {
|
|
26
38
|
const {
|
|
27
39
|
value,
|
|
28
|
-
step,
|
|
29
40
|
disabled,
|
|
30
|
-
|
|
41
|
+
readOnly,
|
|
31
42
|
drag,
|
|
32
43
|
dragSensitivity,
|
|
33
44
|
pointerLock,
|
|
45
|
+
dragCursor,
|
|
34
46
|
rawRange,
|
|
35
47
|
changeValue,
|
|
36
48
|
} = useNumberInputContext()
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
)
|
|
49
|
+
// Only attached while it can do something: `createDrag` puts
|
|
50
|
+
// `touch-action: none` on the element, and a stepper that cannot be
|
|
51
|
+
// dragged has no reason to stop the page scrolling under a finger.
|
|
52
|
+
const enabled = drag !== null && !disabled && !readOnly
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
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)
|
|
54
|
+
// See useDrag for why the node is held in state rather than a ref.
|
|
55
|
+
const [node, setNode] = useState<HTMLDivElement | null>(null)
|
|
56
|
+
const instanceRef = useRef<StepperDragInstance | null>(null)
|
|
57
|
+
const latest = useRef({ value, changeValue })
|
|
58
|
+
const options = {
|
|
59
|
+
range: rawRange,
|
|
60
|
+
pixels: drag ?? 1,
|
|
61
|
+
sensitivity: dragSensitivity,
|
|
62
|
+
pointerLock,
|
|
63
|
+
cursor: dragCursor,
|
|
64
|
+
}
|
|
65
|
+
const optionsRef = useRef(options)
|
|
74
66
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}
|
|
67
|
+
// Runs after every render.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
latest.current = { value, changeValue }
|
|
70
|
+
optionsRef.current = options
|
|
71
|
+
instanceRef.current?.update(options)
|
|
72
|
+
})
|
|
91
73
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!node || !enabled) return
|
|
76
|
+
const instance = createStepperDrag(node, {
|
|
77
|
+
...optionsRef.current,
|
|
78
|
+
getValue: () => latest.current.value,
|
|
79
|
+
onChange: (next) => latest.current.changeValue(next),
|
|
80
|
+
})
|
|
81
|
+
instanceRef.current = instance
|
|
82
|
+
return () => {
|
|
83
|
+
instanceRef.current = null
|
|
84
|
+
instance.destroy()
|
|
85
|
+
}
|
|
86
|
+
}, [node, enabled])
|
|
102
87
|
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
})
|
|
88
|
+
const gesture = useNumberInputGesture()
|
|
89
|
+
const composedRef = useComposedRefs<HTMLDivElement>(forwardedRef, setNode)
|
|
123
90
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
91
|
+
const context = useMemo(
|
|
92
|
+
() => ({ moved: () => instanceRef.current?.moved() ?? false }),
|
|
93
|
+
[],
|
|
127
94
|
)
|
|
128
95
|
|
|
129
|
-
const context = useMemo(() => ({ draggingRef }), [])
|
|
130
|
-
|
|
131
96
|
return (
|
|
132
97
|
<StepperProvider value={context}>
|
|
133
|
-
<div
|
|
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
|
+
>
|
|
134
110
|
{children}
|
|
135
111
|
</div>
|
|
136
112
|
</StepperProvider>
|