@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.
Files changed (62) hide show
  1. package/dist/index.cjs +1125 -1437
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +561 -243
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +561 -243
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1125 -1438
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -10
  11. package/src/components/AnimationCanvas/index.tsx +84 -155
  12. package/src/components/Knob/ActiveLine.tsx +6 -6
  13. package/src/components/Knob/InactiveLine.tsx +9 -11
  14. package/src/components/Knob/SVGRoot.tsx +9 -18
  15. package/src/components/Knob/context.tsx +57 -89
  16. package/src/components/Knob/index.css +6 -2
  17. package/src/components/Knob/index.tsx +90 -61
  18. package/src/components/NumberInput/DecrementStepper.tsx +17 -50
  19. package/src/components/NumberInput/IncrementStepper.tsx +17 -50
  20. package/src/components/NumberInput/InputField.tsx +123 -0
  21. package/src/components/NumberInput/Stepper.tsx +90 -12
  22. package/src/components/NumberInput/context.tsx +74 -146
  23. package/src/components/NumberInput/index.css +38 -102
  24. package/src/components/NumberInput/index.tsx +252 -98
  25. package/src/components/NumberInput/stepperButton.tsx +90 -0
  26. package/src/components/Piano/index.tsx +272 -288
  27. package/src/components/Piano/keyboardShortcuts.ts +34 -4
  28. package/src/components/PointsEditor/Background.tsx +8 -2
  29. package/src/components/PointsEditor/Container.tsx +19 -9
  30. package/src/components/PointsEditor/Point.tsx +104 -24
  31. package/src/components/PointsEditor/context.tsx +28 -74
  32. package/src/components/PointsEditor/index.css +5 -0
  33. package/src/components/PointsEditor/index.tsx +77 -43
  34. package/src/components/Slider/{Scale.tsx → Marks.tsx} +8 -8
  35. package/src/components/Slider/{ScaleOption.tsx → MarksOption.tsx} +16 -18
  36. package/src/components/Slider/Thumb.tsx +55 -68
  37. package/src/components/Slider/Track.tsx +15 -15
  38. package/src/components/Slider/context.tsx +36 -51
  39. package/src/components/Slider/index.css +18 -5
  40. package/src/components/Slider/index.tsx +127 -146
  41. package/src/components/Slider/type.ts +4 -4
  42. package/src/components/XYPad/Area.tsx +16 -7
  43. package/src/components/XYPad/Thumb.tsx +62 -74
  44. package/src/components/XYPad/context.tsx +46 -0
  45. package/src/components/XYPad/index.css +12 -0
  46. package/src/components/XYPad/index.tsx +190 -230
  47. package/src/hooks/useDrag.ts +1 -1
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +29 -9
  51. package/src/index.ts +15 -9
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/NumberInput/InternalInput.tsx +0 -216
  54. package/src/components/NumberInput/type.ts +0 -58
  55. package/src/components/Piano/KeyLabel.tsx +0 -51
  56. package/src/components/Piano/context.tsx +0 -73
  57. package/src/components/Piano/key.tsx +0 -205
  58. package/src/components/_util/type.ts +0 -1
  59. package/src/hooks/useDragWithElement.ts +0 -100
  60. package/src/hooks/usePianoDrag.ts +0 -75
  61. package/src/hooks/useRefCallbackEvent.ts +0 -34
  62. /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
@@ -1,64 +1,87 @@
1
1
  import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
2
+ import {
3
+ ComponentPropsWithoutRef,
4
+ CSSProperties,
5
+ forwardRef,
6
+ ReactNode,
7
+ useCallback,
8
+ useImperativeHandle,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react'
3
13
 
4
- import { InputEventOption } from '@tremolo-ui/functions'
14
+ import {
15
+ applyDelta,
16
+ clamp,
17
+ formatValue,
18
+ InputEventOption,
19
+ linearScale,
20
+ parseValue,
21
+ type Scale,
22
+ type Units,
23
+ type ValueRange,
24
+ } from '@tremolo-ui/functions'
25
+
26
+ import { useWheel } from '../../hooks/useWheel'
5
27
 
6
28
  import { NumberInputProvider } from './context'
7
29
  import { DecrementStepper } from './DecrementStepper'
8
30
  import { IncrementStepper } from './IncrementStepper'
9
- import { InternalInput } from './InternalInput'
31
+ import { InputField } from './InputField'
10
32
  import { Stepper } from './Stepper'
11
- import { Units } from './type'
12
-
13
- /*
14
- TODO
15
- - numberMode: focusが当たった時だけ、parse + type="number"にする
16
- - skew
17
- */
18
33
 
19
34
  export interface NumberInputProps {
20
- value: number | string
21
-
22
35
  /**
23
- * Number.MIN_SAFE_INTEGER
36
+ * The value. What the input shows is `format(value)`, except while the user
37
+ * is typing, when their own text stands until it is committed.
24
38
  */
39
+ value: number
40
+
25
41
  min?: number
26
- /**
27
- * Number.MAX_SAFE_INTEGER
28
- */
29
42
  max?: number
30
43
  step?: number
44
+ /**
45
+ * How the value is distributed across the travel of a drag or a
46
+ * `'normalized'` wheel / keyboard nudge.
47
+ *
48
+ * @default linearScale
49
+ */
50
+ scale?: Scale
31
51
 
32
52
  /**
53
+ * Unit to display the value in, or a list to pick from by magnitude.
54
+ * Builds the default `format` and `parse`.
55
+ *
33
56
  * @example
34
57
  * units='Hz'
35
58
  * units={[['Hz', 1], ['kHz', 1000]]}
36
59
  * units={[['ms', 1], ['s', 1000]]}
37
60
  */
38
61
  units?: string | Units
39
-
40
62
  /**
41
- * Digits for rounding numbers.
63
+ * Digits after the decimal point, for the default `format`.
64
+ *
42
65
  * @example
43
- * with:
44
- * units={[['Hz', 1], ['kHz', 1000]]}
45
- * digit={3}
46
- * results:
47
- * value=100 -> 100Hz, value=1600 -> 1.60Hz
66
+ * // units={[['Hz', 1], ['kHz', 1000]]} digit={2}
67
+ * // 100 -> 100.00Hz, 1600 -> 1.60kHz
48
68
  */
49
69
  digit?: number
70
+ /** Render the value as text. Takes precedence over `units` / `digit`. */
71
+ format?: (value: number) => string
72
+ /** Read a value back out of the text. Takes precedence over `units`. */
73
+ parse?: (text: string) => number
50
74
 
51
- readonly?: boolean
52
-
53
- variant?: 'outline' | 'filled' | 'flushed' | 'unstyled'
54
-
55
- selectWithFocus?: 'all' | 'number' | 'none'
56
- blurOnEnter?: boolean
57
- keepWithinRange?: boolean
58
- clampValueOnBlur?: boolean
75
+ /**
76
+ * Keep the value within `min` and `max` when it is committed or stepped.
77
+ * Typing is never clamped, so that a value can be entered digit by digit.
78
+ * @default true
79
+ */
80
+ clampValue?: boolean
59
81
 
60
82
  /**
61
- * wheel control option
83
+ * Wheel control option. Only applies while the focus is inside, so that
84
+ * scrolling past the input does not change it.
62
85
  * If null, no event will be triggered
63
86
  */
64
87
  wheel?: InputEventOption | null
@@ -67,24 +90,43 @@ export interface NumberInputProps {
67
90
  * If null, no event will be triggered
68
91
  */
69
92
  keyboard?: InputEventOption | null
93
+ /**
94
+ * Pixels of vertical drag on `Stepper` that move the value by one `step`.
95
+ * If null, no event will be triggered
96
+ * @default 1
97
+ */
98
+ drag?: number | null
70
99
 
71
- activeColor?: string
72
-
73
- wrapperClassName?: string
100
+ /**
101
+ * Only the appearance will change.
102
+ * Please consider using with readonly.
103
+ * aria-disabled property is also applied.
104
+ */
105
+ disabled?: boolean
106
+ /**
107
+ * Make the value unchangeable.
108
+ * aria-readonly property is also applied.
109
+ */
110
+ readonly?: boolean
74
111
 
75
- onChange?: (value: number, text: string) => void
76
- onFocus?: (
77
- value: number,
78
- text: string,
79
- event: React.FocusEvent<HTMLInputElement, Element>,
80
- ) => void
81
- onBlur?: (
82
- value: number,
83
- text: string,
84
- event: React.FocusEvent<HTMLInputElement, Element>,
85
- ) => void
112
+ className?: string
113
+ style?: CSSProperties
114
+ onChange?: (value: number) => void
86
115
 
87
- children?: ReactNode
116
+ /**
117
+ * The input renders exactly what you compose here; there is no default
118
+ * markup to fall back to.
119
+ *
120
+ * @example
121
+ * <NumberInput.Root value={value} min={0} max={100} onChange={setValue}>
122
+ * <NumberInput.InputField />
123
+ * <NumberInput.Stepper>
124
+ * <NumberInput.IncrementStepper />
125
+ * <NumberInput.DecrementStepper />
126
+ * </NumberInput.Stepper>
127
+ * </NumberInput.Root>
128
+ */
129
+ children: ReactNode
88
130
  }
89
131
 
90
132
  export interface NumberInputMethods {
@@ -93,77 +135,187 @@ export interface NumberInputMethods {
93
135
  }
94
136
 
95
137
  type Props = NumberInputProps &
96
- Omit<ComponentPropsWithoutRef<'input'>, keyof NumberInputProps | 'type'>
138
+ Omit<ComponentPropsWithoutRef<'div'>, keyof NumberInputProps>
97
139
 
98
- const Root = forwardRef<NumberInputMethods, Props>(
140
+ export const Root = forwardRef<NumberInputMethods, Props>(
99
141
  (
100
142
  {
101
143
  value,
102
144
  min,
103
145
  max,
104
146
  step = 1,
147
+ scale = linearScale,
105
148
  units,
106
- readonly = false,
107
- disabled = false,
108
149
  digit,
109
- variant = 'outline',
110
- selectWithFocus = 'none',
111
- blurOnEnter = true,
112
- keepWithinRange = true,
113
- clampValueOnBlur = true,
150
+ format: formatProp,
151
+ parse: parseProp,
152
+ clampValue = true,
114
153
  wheel = ['raw', 1],
115
154
  keyboard = ['raw', 1],
116
- activeColor,
117
- wrapperClassName,
155
+ drag = 1,
156
+ disabled = false,
157
+ readonly = false,
118
158
  className,
159
+ style,
119
160
  onChange,
120
- onFocus,
121
- onBlur,
122
161
  children,
123
162
  ...props
124
163
  }: Props,
125
164
  forwardedRef,
126
165
  ) => {
127
- const colors: Record<string, string | undefined> = {
128
- '--active-color': activeColor,
129
- }
166
+ // --- state and ref ---
167
+ const inputRef = useRef<HTMLInputElement>(null)
168
+ /**
169
+ * The text being typed. The only state here: it is not a copy of `value`,
170
+ * but the half-finished entry that has no value to be derived from yet.
171
+ */
172
+ const [draft, setDraft] = useState<string | null>(null)
173
+
174
+ // --- interpret props ---
175
+ const format = useCallback(
176
+ (v: number) =>
177
+ formatProp ? formatProp(v) : formatValue(v, units, digit),
178
+ [formatProp, units, digit],
179
+ )
180
+ const parse = useCallback(
181
+ (t: string) => (parseProp ? parseProp(t) : parseValue(t, units)),
182
+ [parseProp, units],
183
+ )
184
+
185
+ // An unbounded end, and a range the caller opted out of enforcing, both
186
+ // become the widest range the value pipeline can express.
187
+ const range: ValueRange = useMemo(
188
+ () => ({
189
+ min: (clampValue ? min : undefined) ?? Number.MIN_SAFE_INTEGER,
190
+ max: (clampValue ? max : undefined) ?? Number.MAX_SAFE_INTEGER,
191
+ step,
192
+ scale,
193
+ }),
194
+ [clampValue, min, max, step, scale],
195
+ )
196
+
197
+ const text = draft ?? format(value)
198
+ const editing = draft != null
199
+ const outOfRange =
200
+ !editing &&
201
+ ((min != undefined && value < min) || (max != undefined && value > max))
202
+
203
+ // --- internal functions ---
204
+ const handleDraft = useCallback(
205
+ (next: string) => {
206
+ if (readonly) return
207
+ setDraft(next)
208
+ // Deliberately unclamped: clamping here would make "1500" impossible
209
+ // to type into an input whose max is 100.
210
+ const parsed = parse(next)
211
+ if (Number.isFinite(parsed)) onChange?.(parsed)
212
+ },
213
+ [readonly, parse, onChange],
214
+ )
215
+
216
+ const changeValue = useCallback(
217
+ (next: number) => {
218
+ if (readonly) return
219
+ setDraft(null)
220
+ if (next != value) onChange?.(next)
221
+ },
222
+ [readonly, value, onChange],
223
+ )
224
+
225
+ const commitDraft = useCallback(() => {
226
+ if (draft == null || readonly) return
227
+ // `range` is already the widest possible range when clampValue is off.
228
+ changeValue(clamp(parse(draft), range.min, range.max))
229
+ }, [draft, readonly, parse, range, changeValue])
230
+
231
+ const nudge = useCallback(
232
+ (direction: number, option: InputEventOption) => {
233
+ changeValue(applyDelta(value, direction, option, range))
234
+ },
235
+ [changeValue, value, range],
236
+ )
237
+
238
+ // --- hooks ---
239
+ const wheelRefCallback = useWheel<HTMLDivElement>(
240
+ (event) => {
241
+ if (!wheel || readonly || event.deltaY == 0) return
242
+ event.preventDefault()
243
+ nudge(-Math.sign(event.deltaY), wheel)
244
+ },
245
+ { requireFocus: true },
246
+ )
247
+
248
+ const context = useMemo(
249
+ () => ({
250
+ value,
251
+ min,
252
+ max,
253
+ step,
254
+ scale,
255
+ disabled,
256
+ readonly,
257
+ clampValue,
258
+ range,
259
+ keyboard,
260
+ text,
261
+ editing,
262
+ outOfRange,
263
+ atMin: clampValue && min != undefined && value <= min,
264
+ atMax: clampValue && max != undefined && value >= max,
265
+ drag,
266
+ format,
267
+ parse,
268
+ setDraft: handleDraft,
269
+ commitDraft,
270
+ changeValue,
271
+ nudge,
272
+ inputRef,
273
+ }),
274
+ [
275
+ value,
276
+ min,
277
+ max,
278
+ step,
279
+ scale,
280
+ disabled,
281
+ readonly,
282
+ clampValue,
283
+ range,
284
+ keyboard,
285
+ text,
286
+ editing,
287
+ outOfRange,
288
+ drag,
289
+ format,
290
+ parse,
291
+ handleDraft,
292
+ commitDraft,
293
+ changeValue,
294
+ nudge,
295
+ ],
296
+ )
297
+
298
+ useImperativeHandle(forwardedRef, () => {
299
+ return {
300
+ focus() {
301
+ inputRef.current?.focus()
302
+ },
303
+ blur() {
304
+ inputRef.current?.blur()
305
+ },
306
+ }
307
+ }, [])
308
+
130
309
  return (
131
- <NumberInputProvider
132
- value={String(value)}
133
- min={min}
134
- max={max}
135
- step={step}
136
- units={units}
137
- digit={digit}
138
- readonly={readonly}
139
- keepWithinRange={keepWithinRange}
140
- onChange={onChange}
141
- >
310
+ <NumberInputProvider value={context}>
142
311
  <div
143
- className={clsx('tremolo-number-input-wrapper', wrapperClassName)}
144
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
145
- tabIndex={0}
146
- style={{
147
- ...colors,
148
- }}
149
- data-stepper={!!children}
150
- data-variant={variant}
312
+ ref={wheelRefCallback}
313
+ className={clsx('tremolo-number-input', className)}
314
+ aria-disabled={disabled}
315
+ aria-readonly={readonly}
316
+ style={style}
317
+ {...props}
151
318
  >
152
- <InternalInput
153
- ref={forwardedRef}
154
- readonly={readonly}
155
- disabled={disabled}
156
- selectWithFocus={selectWithFocus}
157
- blurOnEnter={blurOnEnter}
158
- clampValueOnBlur={clampValueOnBlur}
159
- wheel={wheel}
160
- keyboard={keyboard}
161
- className={className}
162
- onChange={onChange}
163
- onFocus={onFocus}
164
- onBlur={onBlur}
165
- {...props}
166
- />
167
319
  {children}
168
320
  </div>
169
321
  </NumberInputProvider>
@@ -176,12 +328,14 @@ const Root = forwardRef<NumberInputMethods, Props>(
176
328
  */
177
329
  export const NumberInput = {
178
330
  Root,
331
+ InputField,
179
332
  Stepper,
180
333
  IncrementStepper,
181
334
  DecrementStepper,
182
335
  }
183
336
 
337
+ export { useNumberInputContext } from './context'
338
+ export { type NumberInputFieldProps } from './InputField'
184
339
  export { type StepperProps } from './Stepper'
185
340
  export { type IncrementStepperProps } from './IncrementStepper'
186
341
  export { type DecrementStepperProps } from './DecrementStepper'
187
- export { type Units } from './type'
@@ -0,0 +1,90 @@
1
+ import clsx from 'clsx'
2
+ import {
3
+ ComponentPropsWithoutRef,
4
+ CSSProperties,
5
+ ReactNode,
6
+ useCallback,
7
+ } from 'react'
8
+
9
+ import { useLongPress } from '../../hooks/useLongPress'
10
+
11
+ import { useNumberInputContext, useStepperContext } from './context'
12
+
13
+ export interface StepperButtonProps {
14
+ className?: string
15
+ style?: CSSProperties
16
+ /** Replaces the default arrow. Size it with the `--stepper-icon-size` variable. */
17
+ children?: ReactNode
18
+ }
19
+
20
+ type Props = StepperButtonProps &
21
+ Omit<ComponentPropsWithoutRef<'div'>, keyof StepperButtonProps>
22
+
23
+ /**
24
+ * Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
25
+ * in which way they move the value and which arrow they draw.
26
+ */
27
+ export function StepperButton({
28
+ direction,
29
+ variant,
30
+ icon,
31
+ className,
32
+ style,
33
+ children,
34
+ onPointerDown,
35
+ ...props
36
+ }: Props & {
37
+ direction: 1 | -1
38
+ variant: 'increment' | 'decrement'
39
+ icon: ReactNode
40
+ }) {
41
+ const { step, readonly, atMin, atMax, nudge } = useNumberInputContext()
42
+ const stepper = useStepperContext()
43
+
44
+ const press = useLongPress(
45
+ useCallback(() => {
46
+ // Once the pointer has actually travelled, the drag on `Stepper` owns the
47
+ // value; repeating on top of it would move it twice.
48
+ if (stepper?.draggingRef.current) return
49
+ nudge(direction, ['raw', step])
50
+ }, [stepper, nudge, direction, step]),
51
+ )
52
+
53
+ return (
54
+ // eslint-disable-next-line jsx-a11y/role-supports-aria-props
55
+ <div
56
+ className={clsx(`tremolo-number-input-${variant}-stepper`, className)}
57
+ role="button"
58
+ tabIndex={-1}
59
+ // A bare arrow has no accessible name of its own. Overridable, since a
60
+ // caller may need it in their own language.
61
+ aria-label={direction > 0 ? 'Increment' : 'Decrement'}
62
+ aria-disabled={direction > 0 ? atMax : atMin}
63
+ aria-readonly={readonly}
64
+ style={style}
65
+ onPointerDown={(event) => {
66
+ if (!readonly) press()
67
+ onPointerDown?.(event)
68
+ }}
69
+ {...props}
70
+ >
71
+ {children ?? icon}
72
+ </div>
73
+ )
74
+ }
75
+
76
+ export function StepperArrow({ up }: { up: boolean }) {
77
+ return (
78
+ <svg
79
+ className="tremolo-number-input-stepper-icon"
80
+ viewBox="0 0 24 24"
81
+ fill="none"
82
+ stroke="currentColor"
83
+ strokeWidth="2"
84
+ strokeLinecap="round"
85
+ strokeLinejoin="round"
86
+ >
87
+ <polyline points={up ? '18 15 12 9 6 15' : '6 9 12 15 18 9'} />
88
+ </svg>
89
+ )
90
+ }