@tremolo-ui/react 0.4.0 → 0.5.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 +1125 -1437
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +58 -93
- package/dist/index.d.cts +561 -243
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +561 -243
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1125 -1438
- package/dist/index.js.map +1 -1
- package/package.json +9 -10
- package/src/components/AnimationCanvas/index.tsx +84 -155
- package/src/components/Knob/ActiveLine.tsx +6 -6
- package/src/components/Knob/InactiveLine.tsx +9 -11
- package/src/components/Knob/SVGRoot.tsx +9 -18
- package/src/components/Knob/context.tsx +57 -89
- package/src/components/Knob/index.css +6 -2
- package/src/components/Knob/index.tsx +90 -61
- package/src/components/NumberInput/DecrementStepper.tsx +17 -50
- package/src/components/NumberInput/IncrementStepper.tsx +17 -50
- package/src/components/NumberInput/InputField.tsx +123 -0
- package/src/components/NumberInput/Stepper.tsx +90 -12
- package/src/components/NumberInput/context.tsx +74 -146
- package/src/components/NumberInput/index.css +38 -102
- package/src/components/NumberInput/index.tsx +252 -98
- package/src/components/NumberInput/stepperButton.tsx +90 -0
- package/src/components/Piano/index.tsx +272 -288
- package/src/components/Piano/keyboardShortcuts.ts +34 -4
- package/src/components/PointsEditor/Background.tsx +8 -2
- package/src/components/PointsEditor/Container.tsx +19 -9
- package/src/components/PointsEditor/Point.tsx +104 -24
- package/src/components/PointsEditor/context.tsx +28 -74
- package/src/components/PointsEditor/index.css +5 -0
- package/src/components/PointsEditor/index.tsx +77 -43
- package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
- package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
- package/src/components/Slider/Thumb.tsx +55 -68
- package/src/components/Slider/Track.tsx +15 -15
- package/src/components/Slider/context.tsx +36 -51
- package/src/components/Slider/index.css +18 -5
- package/src/components/Slider/index.tsx +127 -146
- package/src/components/Slider/type.ts +4 -4
- package/src/components/XYPad/Area.tsx +16 -7
- package/src/components/XYPad/Thumb.tsx +62 -74
- package/src/components/XYPad/context.tsx +46 -0
- package/src/components/XYPad/index.css +12 -0
- package/src/components/XYPad/index.tsx +190 -230
- package/src/hooks/useDrag.ts +1 -1
- package/src/hooks/useDragValue.ts +149 -0
- package/src/hooks/useEventListener.ts +1 -1
- package/src/hooks/useWheel.ts +29 -9
- package/src/index.ts +15 -9
- package/src/components/AnimationCanvas/canvas.ts +0 -46
- package/src/components/NumberInput/InternalInput.tsx +0 -216
- package/src/components/NumberInput/type.ts +0 -58
- package/src/components/Piano/KeyLabel.tsx +0 -51
- package/src/components/Piano/context.tsx +0 -73
- package/src/components/Piano/key.tsx +0 -205
- package/src/components/_util/type.ts +0 -1
- package/src/hooks/useDragWithElement.ts +0 -100
- package/src/hooks/usePianoDrag.ts +0 -75
- package/src/hooks/useRefCallbackEvent.ts +0 -34
- /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
|
@@ -2,26 +2,29 @@ import clsx from 'clsx'
|
|
|
2
2
|
import {
|
|
3
3
|
ComponentPropsWithoutRef,
|
|
4
4
|
forwardRef,
|
|
5
|
+
ReactNode,
|
|
5
6
|
useCallback,
|
|
6
7
|
useImperativeHandle,
|
|
8
|
+
useMemo,
|
|
7
9
|
useRef,
|
|
8
|
-
useState,
|
|
9
10
|
} from 'react'
|
|
10
11
|
|
|
12
|
+
import type { AxisOptions, XY } from '@tremolo-ui/dom'
|
|
11
13
|
import {
|
|
12
|
-
|
|
14
|
+
applyDelta,
|
|
13
15
|
InputEventOption,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
linearScale,
|
|
17
|
+
type Scale,
|
|
18
|
+
type ValueRange,
|
|
17
19
|
} from '@tremolo-ui/functions'
|
|
18
20
|
|
|
19
|
-
import {
|
|
21
|
+
import { useDragValue } from '../../hooks/useDragValue'
|
|
20
22
|
import { useWheel } from '../../hooks/useWheel'
|
|
21
23
|
import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
|
|
24
|
+
import { useComposedRefs } from '../_util/composeRefs'
|
|
22
25
|
|
|
23
26
|
import { ActiveLine } from './ActiveLine'
|
|
24
|
-
import { KnobProvider } from './context'
|
|
27
|
+
import { calcAngles, KnobProvider } from './context'
|
|
25
28
|
import { InactiveLine } from './InactiveLine'
|
|
26
29
|
import { SVGRoot } from './SVGRoot'
|
|
27
30
|
import { Thumb } from './Thumb'
|
|
@@ -39,7 +42,16 @@ export interface KnobProps {
|
|
|
39
42
|
|
|
40
43
|
// optional
|
|
41
44
|
step?: number
|
|
42
|
-
|
|
45
|
+
/**
|
|
46
|
+
* How the value is distributed across the travel.
|
|
47
|
+
*
|
|
48
|
+
* Pick one of the scales from `@tremolo-ui/functions`: `linearScale`,
|
|
49
|
+
* `exponentialScale`, `curveScale(n)`, `symmetricSkewScale(n)`, or
|
|
50
|
+
* `skewScale(n)` for a value that has to match a JUCE parameter.
|
|
51
|
+
*
|
|
52
|
+
* @default linearScale
|
|
53
|
+
*/
|
|
54
|
+
scale?: Scale
|
|
43
55
|
/**
|
|
44
56
|
* value set when double-clicking
|
|
45
57
|
* restriction: enableDoubleClickDefault = true
|
|
@@ -55,6 +67,10 @@ export interface KnobProps {
|
|
|
55
67
|
startValue?: number
|
|
56
68
|
|
|
57
69
|
/** width and height */
|
|
70
|
+
/**
|
|
71
|
+
* Width and height of the knob.
|
|
72
|
+
* Defaults to the `--knob-size` CSS variable (50px).
|
|
73
|
+
*/
|
|
58
74
|
size?: number | string
|
|
59
75
|
|
|
60
76
|
/**
|
|
@@ -84,6 +100,21 @@ export interface KnobProps {
|
|
|
84
100
|
angleRange?: number
|
|
85
101
|
|
|
86
102
|
onChange?: (value: number) => void
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The knob renders exactly what you compose here; there is no default
|
|
106
|
+
* markup to fall back to.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* <Knob.Root value={value} min={0} max={100} onChange={setValue}>
|
|
110
|
+
* <Knob.SVGRoot>
|
|
111
|
+
* <Knob.InactiveLine />
|
|
112
|
+
* <Knob.ActiveLine />
|
|
113
|
+
* <Knob.Thumb />
|
|
114
|
+
* </Knob.SVGRoot>
|
|
115
|
+
* </Knob.Root>
|
|
116
|
+
*/
|
|
117
|
+
children: ReactNode
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
export interface KnobMethods {
|
|
@@ -93,14 +124,20 @@ export interface KnobMethods {
|
|
|
93
124
|
|
|
94
125
|
type Props = KnobProps & Omit<ComponentPropsWithoutRef<'div'>, keyof KnobProps>
|
|
95
126
|
|
|
96
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The wheel only acts while the focus is inside, so that scrolling a page past
|
|
129
|
+
* the control does not change its value.
|
|
130
|
+
*/
|
|
131
|
+
const WHEEL_OPTIONS = { requireFocus: true }
|
|
132
|
+
|
|
133
|
+
export const Root = forwardRef<KnobMethods, Props>(
|
|
97
134
|
(
|
|
98
135
|
{
|
|
99
136
|
value,
|
|
100
137
|
min,
|
|
101
138
|
max,
|
|
102
139
|
step = 1,
|
|
103
|
-
|
|
140
|
+
scale = linearScale,
|
|
104
141
|
defaultValue = min,
|
|
105
142
|
startValue = min,
|
|
106
143
|
size,
|
|
@@ -122,35 +159,14 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
122
159
|
}: Props,
|
|
123
160
|
forwardedRef,
|
|
124
161
|
) => {
|
|
125
|
-
const [dragging, setDragging] = useState(false)
|
|
126
|
-
const valueRef = useRef(0)
|
|
127
162
|
const elmRef = useRef<HTMLElement | SVGElement>(null)
|
|
128
163
|
|
|
129
164
|
const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
|
|
130
165
|
|
|
131
166
|
// --- internal functions ---
|
|
132
|
-
const
|
|
133
|
-
(
|
|
134
|
-
|
|
135
|
-
const v = rawValue(valueRef.current - y / 100, min, max, skew)
|
|
136
|
-
const clamped = clamp(stepValue(v, step), min, max)
|
|
137
|
-
onChange(clamped)
|
|
138
|
-
},
|
|
139
|
-
[max, min, onChange, readonly, skew, step],
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
const updateValueByEvent = useCallback(
|
|
143
|
-
(eventType: InputEventOption[0], x: number) => {
|
|
144
|
-
let newValue
|
|
145
|
-
if (eventType == 'normalized') {
|
|
146
|
-
const n = normalizeValue(value, min, max, skew)
|
|
147
|
-
newValue = rawValue(n + x, min, max, skew)
|
|
148
|
-
} else {
|
|
149
|
-
newValue = value + x
|
|
150
|
-
}
|
|
151
|
-
return clamp(stepValue(newValue, step), min, max)
|
|
152
|
-
},
|
|
153
|
-
[max, min, skew, step, value],
|
|
167
|
+
const range: ValueRange = useMemo(
|
|
168
|
+
() => ({ min, max, step, scale }),
|
|
169
|
+
[min, max, step, scale],
|
|
154
170
|
)
|
|
155
171
|
|
|
156
172
|
const handleKeyDown = useCallback(
|
|
@@ -159,25 +175,38 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
159
175
|
const key = event.key
|
|
160
176
|
if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
|
|
161
177
|
event.preventDefault()
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
onChange(updateValueByEvent(keyboard[0], x))
|
|
178
|
+
const direction = key == 'ArrowRight' || key == 'ArrowUp' ? 1 : -1
|
|
179
|
+
onChange(applyDelta(value, direction, keyboard, range))
|
|
165
180
|
}
|
|
166
181
|
},
|
|
167
|
-
[keyboard, onChange, readonly,
|
|
182
|
+
[keyboard, onChange, readonly, value, range],
|
|
168
183
|
)
|
|
169
184
|
|
|
170
185
|
// --- hooks ---
|
|
171
|
-
|
|
186
|
+
// The knob has no travel of its own: the value moves away from where it
|
|
187
|
+
// stood when the drag started, 100px of movement spanning the whole range.
|
|
188
|
+
// Only the vertical axis carries a value, reversed so that dragging up
|
|
189
|
+
// raises it.
|
|
190
|
+
const axis = useMemo(
|
|
191
|
+
(): XY<AxisOptions> => [range, { ...range, reverse: true }],
|
|
192
|
+
[range],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const { refCallback: dragRefCallback, dragging } = useDragValue<
|
|
196
|
+
HTMLElement | SVGElement
|
|
197
|
+
>({
|
|
198
|
+
axis,
|
|
199
|
+
getValue: () => [value, value],
|
|
200
|
+
threshold: 1,
|
|
172
201
|
cursor: readonly ? undefined : externalStyles.cursor,
|
|
173
|
-
|
|
202
|
+
onChange: (v) => {
|
|
203
|
+
if (readonly) return
|
|
204
|
+
onChange?.(v[1])
|
|
205
|
+
},
|
|
174
206
|
onDragStart: () => {
|
|
175
|
-
setDragging(true)
|
|
176
|
-
valueRef.current = normalizeValue(value, min, max, skew)
|
|
177
207
|
if (externalStyles.userSelectNone) addUserSelectNone()
|
|
178
208
|
},
|
|
179
209
|
onDragEnd: () => {
|
|
180
|
-
setDragging(false)
|
|
181
210
|
if (externalStyles.userSelectNone) removeUserSelectNone()
|
|
182
211
|
},
|
|
183
212
|
})
|
|
@@ -186,9 +215,21 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
186
215
|
if (!wheel || readonly) return
|
|
187
216
|
event.preventDefault()
|
|
188
217
|
if (!onChange || event.deltaY == 0) return
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
218
|
+
onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range))
|
|
219
|
+
}, WHEEL_OPTIONS)
|
|
220
|
+
|
|
221
|
+
// Composed once, so React attaches the refs a single time instead of
|
|
222
|
+
// detaching and re-attaching on every render.
|
|
223
|
+
const rootRefCallback = useComposedRefs<HTMLElement | SVGElement>(
|
|
224
|
+
elmRef,
|
|
225
|
+
dragRefCallback,
|
|
226
|
+
wheelRefCallback,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const context = useMemo(() => {
|
|
230
|
+
const config = { value, min, max, step, scale, startValue, angleRange }
|
|
231
|
+
return { ...config, ...calcAngles(config) }
|
|
232
|
+
}, [value, min, max, step, scale, startValue, angleRange])
|
|
192
233
|
|
|
193
234
|
useImperativeHandle(forwardedRef, () => {
|
|
194
235
|
return {
|
|
@@ -202,21 +243,9 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
202
243
|
}, [])
|
|
203
244
|
|
|
204
245
|
return (
|
|
205
|
-
<KnobProvider
|
|
206
|
-
value={value}
|
|
207
|
-
min={min}
|
|
208
|
-
max={max}
|
|
209
|
-
step={step}
|
|
210
|
-
skew={skew}
|
|
211
|
-
startValue={startValue}
|
|
212
|
-
angleRange={angleRange}
|
|
213
|
-
>
|
|
246
|
+
<KnobProvider value={context}>
|
|
214
247
|
<div
|
|
215
|
-
ref={
|
|
216
|
-
elmRef.current = elm
|
|
217
|
-
wheelRefCallback(elm)
|
|
218
|
-
dragRefCallback(elm)
|
|
219
|
-
}}
|
|
248
|
+
ref={rootRefCallback}
|
|
220
249
|
className={clsx('tremolo-knob', className)}
|
|
221
250
|
tabIndex={0}
|
|
222
251
|
role="slider"
|
|
@@ -244,7 +273,7 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
244
273
|
}}
|
|
245
274
|
{...props}
|
|
246
275
|
>
|
|
247
|
-
{children
|
|
276
|
+
{children}
|
|
248
277
|
</div>
|
|
249
278
|
</KnobProvider>
|
|
250
279
|
)
|
|
@@ -1,57 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ComponentPropsWithoutRef, ReactNode } from 'react'
|
|
1
|
+
import { ComponentPropsWithoutRef } from 'react'
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
StepperArrow,
|
|
5
|
+
StepperButton,
|
|
6
|
+
StepperButtonProps,
|
|
7
|
+
} from './stepperButton'
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export interface DecrementStepperProps {
|
|
9
|
-
size?: number
|
|
10
|
-
children?: ReactNode
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function DecrementStepper({
|
|
14
|
-
size = 12,
|
|
15
|
-
children,
|
|
16
|
-
className,
|
|
17
|
-
...props
|
|
18
|
-
}: DecrementStepperProps &
|
|
19
|
-
Omit<ComponentPropsWithoutRef<'div'>, keyof DecrementStepperProps>) {
|
|
20
|
-
const min = useNumberInputContext((s) => s.min)
|
|
21
|
-
const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber)
|
|
22
|
-
const readonly = useNumberInputContext((s) => s.readonly)
|
|
23
|
-
const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange)
|
|
24
|
-
const decrement = useNumberInputContext((s) => s.decrement)
|
|
25
|
-
|
|
26
|
-
const press = useLongPress(decrement)
|
|
9
|
+
export type DecrementStepperProps = StepperButtonProps
|
|
27
10
|
|
|
11
|
+
/** Lowers the value by one `step`, repeating while held. */
|
|
12
|
+
export function DecrementStepper(
|
|
13
|
+
props: DecrementStepperProps &
|
|
14
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof DecrementStepperProps>,
|
|
15
|
+
) {
|
|
28
16
|
return (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
tabIndex={-1}
|
|
34
|
-
aria-disabled={
|
|
35
|
-
keepWithinRange && valueAsNumber <= (min ?? Number.MIN_SAFE_INTEGER)
|
|
36
|
-
}
|
|
37
|
-
aria-readonly={readonly}
|
|
38
|
-
onPointerDown={press}
|
|
17
|
+
<StepperButton
|
|
18
|
+
direction={-1}
|
|
19
|
+
variant="decrement"
|
|
20
|
+
icon={<StepperArrow up={false} />}
|
|
39
21
|
{...props}
|
|
40
|
-
|
|
41
|
-
{children || (
|
|
42
|
-
<svg
|
|
43
|
-
width={size}
|
|
44
|
-
height={size}
|
|
45
|
-
viewBox="0 0 24 24"
|
|
46
|
-
fill="none"
|
|
47
|
-
stroke="currentColor"
|
|
48
|
-
strokeWidth="2"
|
|
49
|
-
strokeLinecap="round"
|
|
50
|
-
strokeLinejoin="round"
|
|
51
|
-
>
|
|
52
|
-
<polyline points="6 9 12 15 18 9" />
|
|
53
|
-
</svg>
|
|
54
|
-
)}
|
|
55
|
-
</div>
|
|
22
|
+
/>
|
|
56
23
|
)
|
|
57
24
|
}
|
|
@@ -1,57 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ComponentPropsWithoutRef, ReactNode } from 'react'
|
|
1
|
+
import { ComponentPropsWithoutRef } from 'react'
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
StepperArrow,
|
|
5
|
+
StepperButton,
|
|
6
|
+
StepperButtonProps,
|
|
7
|
+
} from './stepperButton'
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export interface IncrementStepperProps {
|
|
9
|
-
size?: number
|
|
10
|
-
children?: ReactNode
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function IncrementStepper({
|
|
14
|
-
size = 12,
|
|
15
|
-
children,
|
|
16
|
-
className,
|
|
17
|
-
...props
|
|
18
|
-
}: IncrementStepperProps &
|
|
19
|
-
Omit<ComponentPropsWithoutRef<'div'>, keyof IncrementStepperProps>) {
|
|
20
|
-
const max = useNumberInputContext((s) => s.max)
|
|
21
|
-
const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber)
|
|
22
|
-
const readonly = useNumberInputContext((s) => s.readonly)
|
|
23
|
-
const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange)
|
|
24
|
-
const increment = useNumberInputContext((s) => s.increment)
|
|
25
|
-
|
|
26
|
-
const press = useLongPress(increment)
|
|
9
|
+
export type IncrementStepperProps = StepperButtonProps
|
|
27
10
|
|
|
11
|
+
/** Raises the value by one `step`, repeating while held. */
|
|
12
|
+
export function IncrementStepper(
|
|
13
|
+
props: IncrementStepperProps &
|
|
14
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof IncrementStepperProps>,
|
|
15
|
+
) {
|
|
28
16
|
return (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
tabIndex={-1}
|
|
34
|
-
aria-disabled={
|
|
35
|
-
keepWithinRange && valueAsNumber >= (max ?? Number.MAX_SAFE_INTEGER)
|
|
36
|
-
}
|
|
37
|
-
aria-readonly={readonly}
|
|
38
|
-
onPointerDown={press}
|
|
17
|
+
<StepperButton
|
|
18
|
+
direction={1}
|
|
19
|
+
variant="increment"
|
|
20
|
+
icon={<StepperArrow up />}
|
|
39
21
|
{...props}
|
|
40
|
-
|
|
41
|
-
{children || (
|
|
42
|
-
<svg
|
|
43
|
-
width={size}
|
|
44
|
-
height={size}
|
|
45
|
-
viewBox="0 0 24 24"
|
|
46
|
-
fill="none"
|
|
47
|
-
stroke="currentColor"
|
|
48
|
-
strokeWidth="2"
|
|
49
|
-
strokeLinecap="round"
|
|
50
|
-
strokeLinejoin="round"
|
|
51
|
-
>
|
|
52
|
-
<polyline points="18 15 12 9 6 15" />
|
|
53
|
-
</svg>
|
|
54
|
-
)}
|
|
55
|
-
</div>
|
|
22
|
+
/>
|
|
56
23
|
)
|
|
57
24
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import clsx from 'clsx'
|
|
2
|
+
import { ComponentPropsWithoutRef, CSSProperties, Ref } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useComposedRefs } from '../_util/composeRefs'
|
|
5
|
+
|
|
6
|
+
import { useNumberInputContext } from './context'
|
|
7
|
+
|
|
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
|
+
|
|
21
|
+
className?: string
|
|
22
|
+
style?: CSSProperties
|
|
23
|
+
ref?: Ref<HTMLInputElement>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The leading number of the displayed text, whatever the format put around it. */
|
|
27
|
+
const NUMBER_PREFIX = /^\s*-?[\d.,]*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The text field of a `NumberInput`, and the only place the value can be typed.
|
|
31
|
+
*
|
|
32
|
+
* While the user types, their own text stands rather than `format(value)`, so
|
|
33
|
+
* that a half-finished entry is not rewritten under the caret.
|
|
34
|
+
*/
|
|
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') {
|
|
108
|
+
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
|
+
}
|
|
@@ -1,25 +1,103 @@
|
|
|
1
1
|
import clsx from 'clsx'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ComponentPropsWithoutRef,
|
|
4
|
+
CSSProperties,
|
|
5
|
+
ReactNode,
|
|
6
|
+
Ref,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react'
|
|
10
|
+
|
|
11
|
+
import { applyDelta } from '@tremolo-ui/functions'
|
|
12
|
+
|
|
13
|
+
import { useDrag } from '../../hooks/useDrag'
|
|
14
|
+
import { useComposedRefs } from '../_util/composeRefs'
|
|
15
|
+
|
|
16
|
+
import { StepperProvider, useNumberInputContext } from './context'
|
|
3
17
|
|
|
4
18
|
export interface StepperProps {
|
|
5
|
-
|
|
6
|
-
|
|
19
|
+
className?: string
|
|
20
|
+
style?: CSSProperties
|
|
21
|
+
/** `<NumberInput.IncrementStepper />` and `<NumberInput.DecrementStepper />` go here. */
|
|
7
22
|
children?: ReactNode
|
|
23
|
+
ref?: Ref<HTMLDivElement>
|
|
8
24
|
}
|
|
9
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The area the steppers sit in, and a drag handle in its own right: dragging it
|
|
28
|
+
* up and down moves the value one `step` every `drag` pixels.
|
|
29
|
+
*
|
|
30
|
+
* The drag lives here rather than on `InputField` because `createDrag` turns
|
|
31
|
+
* off text selection on whatever element it is attached to.
|
|
32
|
+
*/
|
|
10
33
|
export function Stepper({
|
|
11
|
-
dynamic = true,
|
|
12
|
-
children,
|
|
13
34
|
className,
|
|
35
|
+
style,
|
|
36
|
+
children,
|
|
37
|
+
ref,
|
|
14
38
|
...props
|
|
15
39
|
}: StepperProps & Omit<ComponentPropsWithoutRef<'div'>, keyof StepperProps>) {
|
|
40
|
+
const { value, step, readonly, drag, range, changeValue } =
|
|
41
|
+
useNumberInputContext()
|
|
42
|
+
|
|
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)
|
|
55
|
+
|
|
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
|
+
})
|
|
83
|
+
|
|
84
|
+
const composedRef = useComposedRefs<HTMLDivElement>(
|
|
85
|
+
ref,
|
|
86
|
+
drag != null && !readonly ? dragRefCallback : undefined,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const context = useMemo(() => ({ draggingRef }), [])
|
|
90
|
+
|
|
16
91
|
return (
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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>
|
|
24
102
|
)
|
|
25
103
|
}
|