@tremolo-ui/react 0.7.0 → 0.8.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 +346 -1015
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +76 -221
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +76 -221
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +333 -978
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/DropZone/index.tsx +2 -1
- package/src/components/FileInput/index.tsx +8 -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 +3 -2
- package/src/components/Knob/context.tsx +3 -74
- package/src/components/Knob/index.tsx +19 -16
- package/src/components/NumberInput/InputField.tsx +12 -27
- package/src/components/NumberInput/Stepper.tsx +49 -97
- package/src/components/NumberInput/StepperButton.tsx +3 -4
- package/src/components/NumberInput/context.tsx +2 -1
- package/src/components/NumberInput/index.tsx +40 -71
- package/src/components/Piano/index.tsx +35 -142
- package/src/components/PointsEditor/Container.tsx +15 -26
- package/src/components/PointsEditor/Point.tsx +38 -85
- package/src/components/PointsEditor/context.tsx +5 -56
- package/src/components/PointsEditor/index.tsx +29 -311
- package/src/components/Slider/Marks.tsx +3 -5
- package/src/components/Slider/MarksOption.tsx +7 -18
- package/src/components/Slider/Track.tsx +2 -1
- package/src/components/Slider/index.tsx +29 -28
- package/src/components/XYPad/Area.tsx +27 -49
- package/src/components/XYPad/Thumb.tsx +3 -1
- package/src/components/XYPad/context.tsx +1 -3
- package/src/components/XYPad/index.tsx +33 -46
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
- package/src/hooks/_internal/useCheckSteps.ts +9 -146
- package/src/hooks/useLongPress.ts +35 -51
- package/src/hooks/useMIDIAccess.ts +0 -8
- package/src/hooks/useMIDIInput.ts +0 -3
- package/src/index.ts +2 -27
- 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/input-event.ts +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tremolo-ui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "React component library for audio app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"react-dom": "^18 || ^19"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@tremolo-ui/dom": "^0.
|
|
45
|
-
"@tremolo-ui/functions": "^0.
|
|
44
|
+
"@tremolo-ui/dom": "^0.8.0",
|
|
45
|
+
"@tremolo-ui/functions": "^0.8.0"
|
|
46
46
|
},
|
|
47
47
|
"files": [
|
|
48
48
|
"dist",
|
|
@@ -39,7 +39,8 @@ export interface DropZoneProps {
|
|
|
39
39
|
onDrop?: (files: File[], event: DragEvent) => void
|
|
40
40
|
/**
|
|
41
41
|
* Called with the dropped files that do not match `accept`, so that the
|
|
42
|
-
* reason can be shown.
|
|
42
|
+
* reason can be shown. It comes after `onDrop` for the same drop, so a list
|
|
43
|
+
* of rejected files can be cleared in `onDrop` and filled here.
|
|
43
44
|
*/
|
|
44
45
|
onReject?: (files: File[], event: DragEvent) => void
|
|
45
46
|
|
|
@@ -7,9 +7,7 @@ import {
|
|
|
7
7
|
useMemo,
|
|
8
8
|
} from 'react'
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
import { visuallyHiddenStyle } from '../_util/visually-hidden'
|
|
10
|
+
import { partitionByAccept, visuallyHiddenStyle } from '@tremolo-ui/dom'
|
|
13
11
|
|
|
14
12
|
import { FileInputProvider } from './context'
|
|
15
13
|
import { Trigger } from './Trigger'
|
|
@@ -47,7 +45,8 @@ export interface FileInputProps {
|
|
|
47
45
|
/**
|
|
48
46
|
* Called with the files that were picked and do not match `accept`, so that
|
|
49
47
|
* the reason can be shown. Given alongside `onChange` when a selection held
|
|
50
|
-
* both
|
|
48
|
+
* both, and after it, so a list of rejected files can be cleared in
|
|
49
|
+
* `onChange` and filled here.
|
|
51
50
|
*/
|
|
52
51
|
onReject?: (files: File[]) => void
|
|
53
52
|
|
|
@@ -92,22 +91,18 @@ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
|
92
91
|
const context = useMemo(() => ({ inputId, disabled }), [inputId, disabled])
|
|
93
92
|
|
|
94
93
|
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
|
95
|
-
const
|
|
94
|
+
const { accepted, rejected } = partitionByAccept(
|
|
95
|
+
Array.from(event.target.files ?? []),
|
|
96
|
+
accept,
|
|
97
|
+
)
|
|
96
98
|
|
|
97
99
|
// Picking the same file twice fires no second change event while the
|
|
98
100
|
// value is still on the input, so it is cleared as soon as it is read.
|
|
99
101
|
// Nothing is lost: the files are already in hand.
|
|
100
102
|
event.target.value = ''
|
|
101
103
|
|
|
102
|
-
const accepted: File[] = []
|
|
103
|
-
const rejected: File[] = []
|
|
104
|
-
for (const file of picked) {
|
|
105
|
-
if (matchesAccept(file, accept)) accepted.push(file)
|
|
106
|
-
else rejected.push(file)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (rejected.length > 0) onReject?.(rejected)
|
|
110
104
|
if (accepted.length > 0) onChange?.(accepted)
|
|
105
|
+
if (rejected.length > 0) onReject?.(rejected)
|
|
111
106
|
}
|
|
112
107
|
|
|
113
108
|
return (
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
+
import { knobArcPath, knobArcRadius } from '@tremolo-ui/dom'
|
|
4
|
+
|
|
3
5
|
import { useCheckPlacement } from '../_util/Placement'
|
|
4
6
|
|
|
5
|
-
import {
|
|
7
|
+
import { useKnobContext } from './context'
|
|
6
8
|
|
|
7
9
|
export function ActiveLine({
|
|
8
10
|
stroke = 'currentColor',
|
|
@@ -15,12 +17,12 @@ export function ActiveLine({
|
|
|
15
17
|
const r2 = useKnobContext((s) => s.r2)
|
|
16
18
|
const r3 = useKnobContext((s) => s.r3)
|
|
17
19
|
|
|
18
|
-
const radius =
|
|
20
|
+
const radius = knobArcRadius(strokeWidth)
|
|
19
21
|
|
|
20
22
|
return (
|
|
21
23
|
<path
|
|
22
24
|
className={className}
|
|
23
|
-
d={
|
|
25
|
+
d={knobArcPath(r2, r3, radius)}
|
|
24
26
|
fill="none"
|
|
25
27
|
stroke={stroke}
|
|
26
28
|
strokeWidth={strokeWidth}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
+
import { knobArcPath, knobArcRadius } from '@tremolo-ui/dom'
|
|
4
|
+
|
|
3
5
|
import { useCheckPlacement } from '../_util/Placement'
|
|
4
6
|
|
|
5
|
-
import {
|
|
7
|
+
import { useKnobContext } from './context'
|
|
6
8
|
|
|
7
9
|
export function InactiveLine({
|
|
8
10
|
stroke = 'currentColor',
|
|
@@ -21,14 +23,14 @@ export function InactiveLine({
|
|
|
21
23
|
const r3 = useKnobContext((s) => s.r3)
|
|
22
24
|
const r4 = useKnobContext((s) => s.r4)
|
|
23
25
|
|
|
24
|
-
const radius =
|
|
26
|
+
const radius = knobArcRadius(strokeWidth)
|
|
25
27
|
|
|
26
28
|
return (
|
|
27
29
|
<>
|
|
28
30
|
{startValue > min && (
|
|
29
31
|
<path
|
|
30
32
|
className={className}
|
|
31
|
-
d={
|
|
33
|
+
d={knobArcPath(r1, r2, radius)}
|
|
32
34
|
fill="none"
|
|
33
35
|
stroke={stroke}
|
|
34
36
|
strokeWidth={strokeWidth}
|
|
@@ -38,7 +40,7 @@ export function InactiveLine({
|
|
|
38
40
|
{startValue < max && (
|
|
39
41
|
<path
|
|
40
42
|
className={className}
|
|
41
|
-
d={
|
|
43
|
+
d={knobArcPath(r3, r4, radius)}
|
|
42
44
|
fill="none"
|
|
43
45
|
stroke={stroke}
|
|
44
46
|
strokeWidth={strokeWidth}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ReactNode, SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { KNOB_VIEWBOX_SIZE } from '@tremolo-ui/dom'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { Placement } from '../_util/Placement'
|
|
6
6
|
|
|
7
7
|
export interface KnobSVGRootProps {
|
|
8
8
|
/**
|
|
@@ -19,7 +19,7 @@ export function SVGRoot({
|
|
|
19
19
|
}: KnobSVGRootProps & Omit<SVGProps<SVGSVGElement>, keyof KnobSVGRootProps>) {
|
|
20
20
|
return (
|
|
21
21
|
<svg
|
|
22
|
-
viewBox={`0 0 ${
|
|
22
|
+
viewBox={`0 0 ${KNOB_VIEWBOX_SIZE} ${KNOB_VIEWBOX_SIZE}`}
|
|
23
23
|
style={{
|
|
24
24
|
display: 'block',
|
|
25
25
|
...style,
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
+
import { KNOB_VIEWBOX_SIZE } from '@tremolo-ui/dom'
|
|
3
4
|
import { clamp } from '@tremolo-ui/functions'
|
|
4
5
|
|
|
5
6
|
import { useCheckPlacement } from '../_util/Placement'
|
|
6
7
|
|
|
7
|
-
import { useKnobContext
|
|
8
|
+
import { useKnobContext } from './context'
|
|
8
9
|
|
|
9
10
|
export interface KnobThumbProps {
|
|
10
11
|
/**
|
|
@@ -68,7 +69,7 @@ export function Thumb({
|
|
|
68
69
|
<line
|
|
69
70
|
className={classes?.thumbLine}
|
|
70
71
|
x1="50%"
|
|
71
|
-
y1={`${(
|
|
72
|
+
y1={`${(KNOB_VIEWBOX_SIZE - clamp(thumbSize, 0, 100)) / 2}%`}
|
|
72
73
|
x2="50%"
|
|
73
74
|
y2={`${thumbLineLength}%`}
|
|
74
75
|
stroke={thumbLine}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
export const viewBoxSize = 100
|
|
6
|
-
export const center = viewBoxSize / 2
|
|
3
|
+
import { type KnobAngles } from '@tremolo-ui/dom'
|
|
4
|
+
import { type Scale } from '@tremolo-ui/functions'
|
|
7
5
|
|
|
8
6
|
export type KnobConfig = {
|
|
9
7
|
value: number
|
|
@@ -16,76 +14,7 @@ export type KnobConfig = {
|
|
|
16
14
|
angleRange: number
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
export type KnobContextValue = KnobConfig &
|
|
20
|
-
/** normalized value */
|
|
21
|
-
p: number
|
|
22
|
-
/** ロータリー開始位置 */
|
|
23
|
-
r1: number
|
|
24
|
-
/** activeLine の開始位置 */
|
|
25
|
-
r2: number
|
|
26
|
-
/** activeLine の終了位置 */
|
|
27
|
-
r3: number
|
|
28
|
-
/** ロータリー終了位置 */
|
|
29
|
-
r4: number
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 角度から円弧上の点を求める。
|
|
34
|
-
*
|
|
35
|
-
* 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
|
|
36
|
-
* それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
|
|
37
|
-
*/
|
|
38
|
-
export function pointOnArc(angle: number, radius: number) {
|
|
39
|
-
return {
|
|
40
|
-
x: center + radius * Math.cos(radian(angle - 90)),
|
|
41
|
-
y: center + radius * Math.sin(radian(angle - 90)),
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 線の太さが viewBox からはみ出さないようにした半径 */
|
|
46
|
-
export function arcRadius(strokeWidth: number | string | undefined) {
|
|
47
|
-
const width =
|
|
48
|
-
typeof strokeWidth === 'number'
|
|
49
|
-
? strokeWidth
|
|
50
|
-
: Number.parseFloat(String(strokeWidth))
|
|
51
|
-
return Number.isFinite(width) ? center - width / 2 : center
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Build an SVG path for an arc, splitting full turns into drawable segments. */
|
|
55
|
-
export function arcPath(startAngle: number, endAngle: number, radius: number) {
|
|
56
|
-
const start = pointOnArc(startAngle, radius)
|
|
57
|
-
const sweep = endAngle - startAngle
|
|
58
|
-
const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180))
|
|
59
|
-
const segmentSweep = sweep / segmentCount
|
|
60
|
-
let path = `M ${start.x} ${start.y}`
|
|
61
|
-
|
|
62
|
-
for (let i = 1; i <= segmentCount; i += 1) {
|
|
63
|
-
const end = pointOnArc(startAngle + segmentSweep * i, radius)
|
|
64
|
-
path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return path
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
|
|
71
|
-
export function calcAngles({
|
|
72
|
-
value,
|
|
73
|
-
min,
|
|
74
|
-
max,
|
|
75
|
-
scale,
|
|
76
|
-
startValue,
|
|
77
|
-
angleRange,
|
|
78
|
-
}: KnobConfig) {
|
|
79
|
-
const p = scale.normalize(value, min, max)
|
|
80
|
-
const s = scale.normalize(startValue, min, max)
|
|
81
|
-
|
|
82
|
-
const r1 = -angleRange / 2
|
|
83
|
-
const r2 = r1 + Math.min(p, s) * angleRange
|
|
84
|
-
const r3 = r1 + Math.max(p, s) * angleRange
|
|
85
|
-
const r4 = angleRange / 2
|
|
86
|
-
|
|
87
|
-
return { p, r1, r2, r3, r4 }
|
|
88
|
-
}
|
|
17
|
+
export type KnobContextValue = KnobConfig & KnobAngles
|
|
89
18
|
|
|
90
19
|
const KnobContext = /* @__PURE__ */ createContext<KnobContextValue | null>(null)
|
|
91
20
|
|
|
@@ -11,10 +11,17 @@ import {
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
applyDelta,
|
|
14
|
+
arrowKeyDirection,
|
|
14
15
|
type AxisOptions,
|
|
16
|
+
cssLength,
|
|
17
|
+
DEFAULT_DRAG_SENSITIVITY,
|
|
18
|
+
DEFAULT_KEYBOARD_OPTIONS,
|
|
19
|
+
DEFAULT_WHEEL_OPTIONS,
|
|
15
20
|
type InputEventOption,
|
|
21
|
+
knobAngles,
|
|
16
22
|
type ModifierValue,
|
|
17
23
|
selectModifier,
|
|
24
|
+
wheelDirection,
|
|
18
25
|
type XY,
|
|
19
26
|
} from '@tremolo-ui/dom'
|
|
20
27
|
import { linearScale, type Scale, type ValueRange } from '@tremolo-ui/functions'
|
|
@@ -23,15 +30,9 @@ import { useComposedRefs } from '../../compose-refs'
|
|
|
23
30
|
import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
|
|
24
31
|
import { useDragValue } from '../../hooks/useDragValue'
|
|
25
32
|
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
33
|
|
|
33
34
|
import { ActiveLine } from './ActiveLine'
|
|
34
|
-
import {
|
|
35
|
+
import { KnobProvider } from './context'
|
|
35
36
|
import { InactiveLine } from './InactiveLine'
|
|
36
37
|
import { SVGRoot } from './SVGRoot'
|
|
37
38
|
import { Thumb } from './Thumb'
|
|
@@ -265,12 +266,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
265
266
|
const handleKeyDown = useCallback(
|
|
266
267
|
(event: React.KeyboardEvent<HTMLOrSVGElement>) => {
|
|
267
268
|
if (!keyboard || !onChange || inactive) return
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
onChange(applyDelta(value, direction, keyboard, range, event))
|
|
273
|
-
}
|
|
269
|
+
const direction = arrowKeyDirection(event.key)
|
|
270
|
+
if (direction === null) return
|
|
271
|
+
event.preventDefault()
|
|
272
|
+
onChange(applyDelta(value, direction, keyboard, range, event))
|
|
274
273
|
},
|
|
275
274
|
[keyboard, onChange, inactive, value, range],
|
|
276
275
|
)
|
|
@@ -304,9 +303,13 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
304
303
|
|
|
305
304
|
const wheelRefCallback = useWheel<HTMLElement>((event) => {
|
|
306
305
|
if (!wheel || inactive) return
|
|
306
|
+
// A notch the knob does not read — a sideways scroll — is left to the
|
|
307
|
+
// page rather than swallowed.
|
|
308
|
+
const direction = wheelDirection(event)
|
|
309
|
+
if (direction === null) return
|
|
307
310
|
event.preventDefault()
|
|
308
|
-
if (!onChange
|
|
309
|
-
onChange(applyDelta(value,
|
|
311
|
+
if (!onChange) return
|
|
312
|
+
onChange(applyDelta(value, direction, wheel, range, event))
|
|
310
313
|
}, WHEEL_OPTIONS)
|
|
311
314
|
|
|
312
315
|
// Composed once, so React attaches the refs a single time instead of
|
|
@@ -319,7 +322,7 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
319
322
|
|
|
320
323
|
const context = useMemo(() => {
|
|
321
324
|
const config = { value, min, max, step, scale, startValue, angleRange }
|
|
322
|
-
return { ...config, ...
|
|
325
|
+
return { ...config, ...knobAngles(config) }
|
|
323
326
|
}, [value, min, max, step, scale, startValue, angleRange])
|
|
324
327
|
|
|
325
328
|
useImperativeHandle(forwardedRef, () => {
|
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
} from 'react'
|
|
9
9
|
|
|
10
|
+
import {
|
|
11
|
+
caretAtDecimalOffset,
|
|
12
|
+
caretDecimalOffset,
|
|
13
|
+
numberSpan,
|
|
14
|
+
} from '@tremolo-ui/dom'
|
|
15
|
+
|
|
10
16
|
import { useComposedRefs } from '../../compose-refs'
|
|
11
17
|
|
|
12
18
|
import { useNumberInputContext } from './context'
|
|
@@ -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 {
|
|
@@ -87,10 +74,8 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
87
74
|
if (selectOnFocus === 'all') {
|
|
88
75
|
input.setSelectionRange(0, input.value.length)
|
|
89
76
|
} else {
|
|
90
|
-
input.
|
|
91
|
-
|
|
92
|
-
input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
|
|
93
|
-
)
|
|
77
|
+
const span = numberSpan(input.value)
|
|
78
|
+
if (span) input.setSelectionRange(span.start, span.end)
|
|
94
79
|
}
|
|
95
80
|
// Deliberately not re-run as the text changes: that would drag the
|
|
96
81
|
// selection back over what the user is typing.
|
|
@@ -114,8 +99,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
114
99
|
// caret has not moved either, since the key press was prevented.
|
|
115
100
|
if (!input || input.value === pending.from) return
|
|
116
101
|
|
|
117
|
-
const
|
|
118
|
-
const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
|
|
102
|
+
const caret = caretAtDecimalOffset(input.value, pending.offset)
|
|
119
103
|
input.setSelectionRange(caret, caret)
|
|
120
104
|
})
|
|
121
105
|
|
|
@@ -154,6 +138,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
154
138
|
// stepped one no longer means anything.
|
|
155
139
|
caretAfterStep.current = null
|
|
156
140
|
setDraft(event.currentTarget.value)
|
|
141
|
+
onChange?.(event)
|
|
157
142
|
}}
|
|
158
143
|
onFocus={(event) => {
|
|
159
144
|
setFocused(true)
|
|
@@ -185,7 +170,7 @@ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
|
|
|
185
170
|
const input = event.currentTarget
|
|
186
171
|
if (keepCaretOnStep && input.selectionStart !== null) {
|
|
187
172
|
caretAfterStep.current = {
|
|
188
|
-
offset: input.
|
|
173
|
+
offset: caretDecimalOffset(input.value, input.selectionStart),
|
|
189
174
|
from: input.value,
|
|
190
175
|
}
|
|
191
176
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { ComponentPropsWithoutRef, forwardRef, useMemo, useRef } from 'react'
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
ComponentPropsWithoutRef,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react'
|
|
9
|
+
|
|
10
|
+
import { createStepperDrag, type StepperDragInstance } from '@tremolo-ui/dom'
|
|
9
11
|
|
|
10
12
|
import { useComposedRefs } from '../../compose-refs'
|
|
11
|
-
import { useDrag } from '../../hooks/useDrag'
|
|
12
13
|
|
|
13
14
|
import { StepperProvider, useNumberInputContext } from './context'
|
|
14
15
|
|
|
@@ -16,7 +17,8 @@ type Props = ComponentPropsWithoutRef<'div'>
|
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* 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.
|
|
20
|
+
* up and down moves the value one `step` every `drag` pixels. The counting is
|
|
21
|
+
* `createStepperDrag` in the core.
|
|
20
22
|
*
|
|
21
23
|
* The drag lives here rather than on `InputField` because `createDrag` turns
|
|
22
24
|
* off text selection on whatever element it is attached to.
|
|
@@ -25,7 +27,6 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
|
25
27
|
function Stepper({ className, style, children, ...props }, forwardedRef) {
|
|
26
28
|
const {
|
|
27
29
|
value,
|
|
28
|
-
step,
|
|
29
30
|
disabled,
|
|
30
31
|
readonly,
|
|
31
32
|
drag,
|
|
@@ -34,100 +35,51 @@ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
|
34
35
|
rawRange,
|
|
35
36
|
changeValue,
|
|
36
37
|
} = 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
|
-
)
|
|
38
|
+
// Only attached while it can do something: `createDrag` puts
|
|
39
|
+
// `touch-action: none` on the element, and a stepper that cannot be
|
|
40
|
+
// dragged has no reason to stop the page scrolling under a finger.
|
|
41
|
+
const enabled = drag !== null && !disabled && !readonly
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
43
|
+
// See useDrag for why the node is held in state rather than a ref.
|
|
44
|
+
const [node, setNode] = useState<HTMLDivElement | null>(null)
|
|
45
|
+
const instanceRef = useRef<StepperDragInstance | null>(null)
|
|
46
|
+
const latest = useRef({ value, changeValue })
|
|
47
|
+
const options = {
|
|
48
|
+
range: rawRange,
|
|
49
|
+
pixels: drag ?? 1,
|
|
50
|
+
sensitivity: dragSensitivity,
|
|
51
|
+
pointerLock,
|
|
52
|
+
}
|
|
53
|
+
const optionsRef = useRef(options)
|
|
74
54
|
|
|
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
|
-
}
|
|
55
|
+
// Runs after every render.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
latest.current = { value, changeValue }
|
|
58
|
+
optionsRef.current = options
|
|
59
|
+
instanceRef.current?.update(options)
|
|
60
|
+
})
|
|
91
61
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!node || !enabled) return
|
|
64
|
+
const instance = createStepperDrag(node, {
|
|
65
|
+
...optionsRef.current,
|
|
66
|
+
getValue: () => latest.current.value,
|
|
67
|
+
onChange: (next) => latest.current.changeValue(next),
|
|
68
|
+
})
|
|
69
|
+
instanceRef.current = instance
|
|
70
|
+
return () => {
|
|
71
|
+
instanceRef.current = null
|
|
72
|
+
instance.destroy()
|
|
73
|
+
}
|
|
74
|
+
}, [node, enabled])
|
|
102
75
|
|
|
103
|
-
|
|
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
|
-
})
|
|
76
|
+
const composedRef = useComposedRefs<HTMLDivElement>(forwardedRef, setNode)
|
|
123
77
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
78
|
+
const context = useMemo(
|
|
79
|
+
() => ({ moved: () => instanceRef.current?.moved() ?? false }),
|
|
80
|
+
[],
|
|
127
81
|
)
|
|
128
82
|
|
|
129
|
-
const context = useMemo(() => ({ draggingRef }), [])
|
|
130
|
-
|
|
131
83
|
return (
|
|
132
84
|
<StepperProvider value={context}>
|
|
133
85
|
<div ref={composedRef} className={className} style={style} {...props}>
|
|
@@ -41,13 +41,12 @@ export function StepperButton({
|
|
|
41
41
|
useNumberInputContext()
|
|
42
42
|
const stepper = useStepperContext()
|
|
43
43
|
|
|
44
|
-
// Not memoized:
|
|
45
|
-
//
|
|
46
|
-
// calls it through a ref, so a fresh identity per render costs nothing.
|
|
44
|
+
// Not memoized: `useLongPress` only ever calls it through a ref, so a fresh
|
|
45
|
+
// identity per render costs nothing.
|
|
47
46
|
const press = useLongPress(() => {
|
|
48
47
|
// Once the pointer has actually travelled, the drag on `Stepper` owns the
|
|
49
48
|
// value; repeating on top of it would move it twice.
|
|
50
|
-
if (stepper?.
|
|
49
|
+
if (stepper?.moved()) return
|
|
51
50
|
nudge(direction, ['raw', step])
|
|
52
51
|
})
|
|
53
52
|
|
|
@@ -103,7 +103,8 @@ export function useNumberInputContext<T>(
|
|
|
103
103
|
|
|
104
104
|
/** Set by `Stepper` once a drag has actually moved, so the steppers stand down. */
|
|
105
105
|
export type StepperContextValue = {
|
|
106
|
-
|
|
106
|
+
/** Whether the drag in progress has moved the value. */
|
|
107
|
+
moved: () => boolean
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
const StepperContext =
|