@tremolo-ui/react 0.3.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 +1162 -1554
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +58 -93
- package/dist/index.d.cts +592 -278
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +592 -278
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1163 -1555
- package/dist/index.js.map +1 -1
- package/package.json +12 -13
- 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 +95 -83
- 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 +117 -52
- 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 +136 -170
- 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 +193 -250
- package/src/hooks/useDrag.ts +41 -76
- package/src/hooks/useDragValue.ts +149 -0
- package/src/hooks/useEventListener.ts +1 -1
- package/src/hooks/useWheel.ts +51 -0
- package/src/index.ts +15 -13
- package/src/components/AnimationCanvas/canvas.ts +0 -46
- package/src/components/DragObserver/index.tsx +0 -58
- package/src/components/NumberInput/InternalInput.tsx +0 -221
- 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/WheelObserver/index.tsx +0 -48
- package/src/components/_util/type.ts +0 -1
- package/src/hooks/useDragWithElement.ts +0 -72
- 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,32 +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 {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
Cursor,
|
|
24
|
-
removeUserSelectNone,
|
|
25
|
-
resetCursorStyle,
|
|
26
|
-
setCursorStyle,
|
|
27
|
-
} from '../_util'
|
|
21
|
+
import { useDragValue } from '../../hooks/useDragValue'
|
|
22
|
+
import { useWheel } from '../../hooks/useWheel'
|
|
23
|
+
import { addUserSelectNone, Cursor, removeUserSelectNone } from '../_util'
|
|
24
|
+
import { useComposedRefs } from '../_util/composeRefs'
|
|
28
25
|
|
|
29
26
|
import { ActiveLine } from './ActiveLine'
|
|
30
|
-
import { KnobProvider } from './context'
|
|
27
|
+
import { calcAngles, KnobProvider } from './context'
|
|
31
28
|
import { InactiveLine } from './InactiveLine'
|
|
32
29
|
import { SVGRoot } from './SVGRoot'
|
|
33
30
|
import { Thumb } from './Thumb'
|
|
@@ -45,7 +42,16 @@ export interface KnobProps {
|
|
|
45
42
|
|
|
46
43
|
// optional
|
|
47
44
|
step?: number
|
|
48
|
-
|
|
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
|
|
49
55
|
/**
|
|
50
56
|
* value set when double-clicking
|
|
51
57
|
* restriction: enableDoubleClickDefault = true
|
|
@@ -61,6 +67,10 @@ export interface KnobProps {
|
|
|
61
67
|
startValue?: number
|
|
62
68
|
|
|
63
69
|
/** width and height */
|
|
70
|
+
/**
|
|
71
|
+
* Width and height of the knob.
|
|
72
|
+
* Defaults to the `--knob-size` CSS variable (50px).
|
|
73
|
+
*/
|
|
64
74
|
size?: number | string
|
|
65
75
|
|
|
66
76
|
/**
|
|
@@ -90,6 +100,21 @@ export interface KnobProps {
|
|
|
90
100
|
angleRange?: number
|
|
91
101
|
|
|
92
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
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
export interface KnobMethods {
|
|
@@ -99,14 +124,20 @@ export interface KnobMethods {
|
|
|
99
124
|
|
|
100
125
|
type Props = KnobProps & Omit<ComponentPropsWithoutRef<'div'>, keyof KnobProps>
|
|
101
126
|
|
|
102
|
-
|
|
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>(
|
|
103
134
|
(
|
|
104
135
|
{
|
|
105
136
|
value,
|
|
106
137
|
min,
|
|
107
138
|
max,
|
|
108
139
|
step = 1,
|
|
109
|
-
|
|
140
|
+
scale = linearScale,
|
|
110
141
|
defaultValue = min,
|
|
111
142
|
startValue = min,
|
|
112
143
|
size,
|
|
@@ -128,35 +159,14 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
128
159
|
}: Props,
|
|
129
160
|
forwardedRef,
|
|
130
161
|
) => {
|
|
131
|
-
const [dragging, setDragging] = useState(false)
|
|
132
|
-
const valueRef = useRef(0)
|
|
133
162
|
const elmRef = useRef<HTMLElement | SVGElement>(null)
|
|
134
163
|
|
|
135
164
|
const externalStyles = { ...defaultExternalStyles, ..._externalStyles }
|
|
136
165
|
|
|
137
166
|
// --- internal functions ---
|
|
138
|
-
const
|
|
139
|
-
(
|
|
140
|
-
|
|
141
|
-
const v = rawValue(valueRef.current - y / 100, min, max, skew)
|
|
142
|
-
const clamped = clamp(stepValue(v, step), min, max)
|
|
143
|
-
onChange(clamped)
|
|
144
|
-
},
|
|
145
|
-
[max, min, onChange, readonly, skew, step],
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
const updateValueByEvent = useCallback(
|
|
149
|
-
(eventType: InputEventOption[0], x: number) => {
|
|
150
|
-
let newValue
|
|
151
|
-
if (eventType == 'normalized') {
|
|
152
|
-
const n = normalizeValue(value, min, max, skew)
|
|
153
|
-
newValue = rawValue(n + x, min, max, skew)
|
|
154
|
-
} else {
|
|
155
|
-
newValue = value + x
|
|
156
|
-
}
|
|
157
|
-
return clamp(stepValue(newValue, step), min, max)
|
|
158
|
-
},
|
|
159
|
-
[max, min, skew, step, value],
|
|
167
|
+
const range: ValueRange = useMemo(
|
|
168
|
+
() => ({ min, max, step, scale }),
|
|
169
|
+
[min, max, step, scale],
|
|
160
170
|
)
|
|
161
171
|
|
|
162
172
|
const handleKeyDown = useCallback(
|
|
@@ -165,45 +175,62 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
165
175
|
const key = event.key
|
|
166
176
|
if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
|
|
167
177
|
event.preventDefault()
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
onChange(updateValueByEvent(keyboard[0], x))
|
|
178
|
+
const direction = key == 'ArrowRight' || key == 'ArrowUp' ? 1 : -1
|
|
179
|
+
onChange(applyDelta(value, direction, keyboard, range))
|
|
171
180
|
}
|
|
172
181
|
},
|
|
173
|
-
[keyboard, onChange, readonly,
|
|
182
|
+
[keyboard, onChange, readonly, value, range],
|
|
174
183
|
)
|
|
175
184
|
|
|
176
185
|
// --- hooks ---
|
|
177
|
-
|
|
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<
|
|
178
196
|
HTMLElement | SVGElement
|
|
179
197
|
>({
|
|
180
|
-
|
|
198
|
+
axis,
|
|
199
|
+
getValue: () => [value, value],
|
|
200
|
+
threshold: 1,
|
|
201
|
+
cursor: readonly ? undefined : externalStyles.cursor,
|
|
202
|
+
onChange: (v) => {
|
|
203
|
+
if (readonly) return
|
|
204
|
+
onChange?.(v[1])
|
|
205
|
+
},
|
|
181
206
|
onDragStart: () => {
|
|
182
|
-
setDragging(true)
|
|
183
|
-
valueRef.current = normalizeValue(value, min, max, skew)
|
|
184
207
|
if (externalStyles.userSelectNone) addUserSelectNone()
|
|
185
|
-
if (externalStyles.cursor) setCursorStyle(externalStyles.cursor)
|
|
186
208
|
},
|
|
187
209
|
onDragEnd: () => {
|
|
188
|
-
setDragging(false)
|
|
189
210
|
if (externalStyles.userSelectNone) removeUserSelectNone()
|
|
190
|
-
if (externalStyles.cursor) resetCursorStyle()
|
|
191
211
|
},
|
|
192
212
|
})
|
|
193
213
|
|
|
194
|
-
const wheelRefCallback =
|
|
195
|
-
|
|
196
|
-
(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
const wheelRefCallback = useWheel<HTMLElement>((event) => {
|
|
215
|
+
if (!wheel || readonly) return
|
|
216
|
+
event.preventDefault()
|
|
217
|
+
if (!onChange || event.deltaY == 0) return
|
|
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,
|
|
205
227
|
)
|
|
206
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])
|
|
233
|
+
|
|
207
234
|
useImperativeHandle(forwardedRef, () => {
|
|
208
235
|
return {
|
|
209
236
|
focus() {
|
|
@@ -216,21 +243,9 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
216
243
|
}, [])
|
|
217
244
|
|
|
218
245
|
return (
|
|
219
|
-
<KnobProvider
|
|
220
|
-
value={value}
|
|
221
|
-
min={min}
|
|
222
|
-
max={max}
|
|
223
|
-
step={step}
|
|
224
|
-
skew={skew}
|
|
225
|
-
startValue={startValue}
|
|
226
|
-
angleRange={angleRange}
|
|
227
|
-
>
|
|
246
|
+
<KnobProvider value={context}>
|
|
228
247
|
<div
|
|
229
|
-
ref={
|
|
230
|
-
elmRef.current = elm
|
|
231
|
-
wheelRefCallback(elm)
|
|
232
|
-
touchMoveRefCallback(elm)
|
|
233
|
-
}}
|
|
248
|
+
ref={rootRefCallback}
|
|
234
249
|
className={clsx('tremolo-knob', className)}
|
|
235
250
|
tabIndex={0}
|
|
236
251
|
role="slider"
|
|
@@ -245,10 +260,7 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
245
260
|
height: size,
|
|
246
261
|
...style,
|
|
247
262
|
}}
|
|
248
|
-
onPointerDown={
|
|
249
|
-
pointerDownHandler(event)
|
|
250
|
-
onPointerDown?.(event)
|
|
251
|
-
}}
|
|
263
|
+
onPointerDown={onPointerDown}
|
|
252
264
|
onDoubleClick={(event) => {
|
|
253
265
|
if (enableDoubleClickDefault && onChange) {
|
|
254
266
|
onChange(defaultValue)
|
|
@@ -261,7 +273,7 @@ const Root = forwardRef<KnobMethods, Props>(
|
|
|
261
273
|
}}
|
|
262
274
|
{...props}
|
|
263
275
|
>
|
|
264
|
-
{children
|
|
276
|
+
{children}
|
|
265
277
|
</div>
|
|
266
278
|
</KnobProvider>
|
|
267
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
|
}
|