@tremolo-ui/react 0.1.4 → 0.1.6

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 (43) hide show
  1. package/dist/index.cjs +285 -155
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +80 -32
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +95 -60
  6. package/dist/index.d.cts.map +1 -1
  7. package/dist/index.d.ts +97 -62
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +262 -139
  10. package/dist/index.js.map +1 -1
  11. package/package.json +14 -9
  12. package/src/components/AnimationCanvas/canvas.ts +16 -0
  13. package/src/components/AnimationCanvas/index.tsx +54 -71
  14. package/src/components/Knob/index.css +54 -23
  15. package/src/components/Knob/index.tsx +30 -25
  16. package/src/components/NumberInput/InternalInput.tsx +1 -1
  17. package/src/components/NumberInput/context.tsx +2 -1
  18. package/src/components/NumberInput/index.css +1 -0
  19. package/src/components/NumberInput/index.tsx +5 -5
  20. package/src/components/NumberInput/type.ts +58 -0
  21. package/src/components/Piano/context.tsx +1 -1
  22. package/src/components/Piano/index.css +24 -8
  23. package/src/components/Piano/index.tsx +254 -219
  24. package/src/components/Piano/key.tsx +3 -3
  25. package/src/components/Slider/Scale.tsx +1 -1
  26. package/src/components/Slider/ScaleOption.tsx +1 -1
  27. package/src/components/Slider/Track.tsx +2 -2
  28. package/src/components/Slider/index.tsx +3 -2
  29. package/src/components/Slider/type.ts +26 -0
  30. package/src/components/XYPad/index.tsx +2 -2
  31. package/src/hooks/useAnimationFrame.ts +3 -0
  32. package/src/hooks/useCallbackRef.ts +2 -2
  33. package/src/hooks/useDrag.ts +2 -1
  34. package/src/hooks/useDragWithElement.ts +2 -1
  35. package/src/hooks/useEventListener.ts +3 -0
  36. package/src/hooks/useInterval.ts +3 -0
  37. package/src/hooks/useLongPress.ts +3 -0
  38. package/src/hooks/useMIDIAccess.ts +40 -0
  39. package/src/hooks/useMIDIInput.ts +50 -0
  40. package/src/hooks/useMIDIMessage.ts +24 -0
  41. package/src/hooks/useRefCallbackEvent.ts +4 -0
  42. package/src/index.ts +21 -13
  43. package/src/styles/{index.css → global.css} +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tremolo-ui/react",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "React component library for audio app",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -16,7 +16,7 @@
16
16
  "serve": "http-server ./storybook-static",
17
17
  "lint": "eslint --fix --ext .ts,.tsx .",
18
18
  "format": "prettier --write .",
19
- "test": "echo 'No test yet.'",
19
+ "test": "jest",
20
20
  "build:package": "tsc && tsdown",
21
21
  "clean": "npm run clean:build && npm run clean:sb",
22
22
  "clean:build": "rimraf ./dist",
@@ -39,14 +39,14 @@
39
39
  "react": "^19",
40
40
  "react-dom": "^19",
41
41
  "storybook": "^8.6.7",
42
- "tone": "^15.0.4"
42
+ "tone": "^15"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "react": "^18 ||^19",
46
46
  "react-dom": "^18 || ^19"
47
47
  },
48
48
  "dependencies": {
49
- "@tremolo-ui/functions": "^0.1.4",
49
+ "@tremolo-ui/functions": "^0.1.6",
50
50
  "clsx": "^2.1.1",
51
51
  "zustand": "^5.0.3"
52
52
  },
@@ -78,14 +78,19 @@
78
78
  "exports": {
79
79
  ".": {
80
80
  "require": {
81
- "default": "./dist/index.cjs",
82
- "types": "./dist/index.d.cts"
81
+ "types": "./dist/index.d.cts",
82
+ "default": "./dist/index.cjs"
83
83
  },
84
84
  "import": {
85
- "default": "./dist/index.js",
86
- "types": "./dist/index.d.ts"
85
+ "types": "./dist/index.d.ts",
86
+ "default": "./dist/index.js"
87
87
  }
88
88
  },
89
- "./index.css": "./dist/index.css"
89
+ "./styles/index.css": "./dist/index.css",
90
+ "./styles/global.css": "./src/styles/global.css",
91
+ "./styles/Knob.css": "./src/components/Knob/index.css",
92
+ "./styles/NumberInput.css": "./src/components/NumberInput/index.css",
93
+ "./styles/Piano.css": "./src/components/Piano/index.css",
94
+ "./styles/XYPad.css": "./src/components/XYPad/index.css"
90
95
  }
91
96
  }
@@ -28,3 +28,19 @@ export function isDrawingState(obj: unknown): obj is DrawingState {
28
28
  const d: string[] = [...drawingState]
29
29
  return typeof obj == 'string' && d.includes(obj)
30
30
  }
31
+
32
+ export function setDprConfig(
33
+ canvas: HTMLCanvasElement,
34
+ context: CanvasRenderingContext2D,
35
+ width: number,
36
+ height: number,
37
+ dpr: number,
38
+ ) {
39
+ canvas.width = width * dpr
40
+ canvas.height = height * dpr
41
+ // Reset current transformation matrix to the identity matrix
42
+ context.setTransform(1, 0, 0, 1, 0, 0)
43
+ context.scale(dpr, dpr)
44
+ canvas.style.width = `${width}px`
45
+ canvas.style.height = `${height}px`
46
+ }
@@ -8,30 +8,12 @@ import {
8
8
  useRef,
9
9
  } from 'react'
10
10
 
11
- import { DrawingContext, drawingState, DrawingStateValue } from './canvas'
12
-
13
- /*
14
- TODO
15
- AbsoluteSizingProps, RelativeSizingPropsの型付けを強くする
16
- AbsoluteSizingPropsの場合、width, heightは必須
17
- RelativeSizingPropsのの場合、relativeSizeは必須、reduceFlickeringはオプショナル
18
- */
19
-
20
- function setDprConfig(
21
- canvas: HTMLCanvasElement,
22
- context: CanvasRenderingContext2D,
23
- width: number,
24
- height: number,
25
- dpr: number,
26
- ) {
27
- canvas.width = width * dpr
28
- canvas.height = height * dpr
29
- // Reset current transformation matrix to the identity matrix
30
- context.setTransform(1, 0, 0, 1, 0, 0)
31
- context.scale(dpr, dpr)
32
- canvas.style.width = `${width}px`
33
- canvas.style.height = `${height}px`
34
- }
11
+ import {
12
+ DrawingContext,
13
+ drawingState,
14
+ DrawingStateValue,
15
+ setDprConfig,
16
+ } from './canvas'
35
17
 
36
18
  /** @category AnimationCanvas */
37
19
  export type InitFunction = (
@@ -46,8 +28,6 @@ export type InitFunction = (
46
28
 
47
29
  /** @category AnimationCanvas */
48
30
  export type DrawFunction = (
49
- // TODO: saving scale and translate
50
- // context: Omit<CanvasRenderingContext2D, 'save' | 'restore' | 'scale'>,
51
31
  context: CanvasRenderingContext2D,
52
32
  option: {
53
33
  /** current canvas width */
@@ -67,12 +47,13 @@ export type DrawFunction = (
67
47
 
68
48
  /** @category AnimationCanvas */
69
49
  export interface CommonProps {
50
+ draw: DrawFunction
51
+ init?: InitFunction
52
+ animate?: boolean
70
53
  /**
71
54
  * @see https://developer.mozilla.org/docs/Web/API/HTMLCanvasElement/getContext#contextattributes
72
55
  */
73
56
  options?: CanvasRenderingContext2DSettings
74
- init?: InitFunction
75
- draw: DrawFunction
76
57
  }
77
58
 
78
59
  /** @category AnimationCanvas */
@@ -108,12 +89,13 @@ export function AnimationCanvas(
108
89
  ): ReactElement
109
90
  export function AnimationCanvas({
110
91
  // common
111
- options,
112
- init,
113
92
  draw,
93
+ init,
94
+ animate = true,
95
+ options,
114
96
  // absolute
115
- width = 100,
116
- height = 100,
97
+ width: _width = 100,
98
+ height: _height = 100,
117
99
  // relative
118
100
  relativeSize,
119
101
  reduceFlickering = true,
@@ -145,9 +127,11 @@ export function AnimationCanvas({
145
127
  const deltaTime = now - deltaMemoRef.current
146
128
  const elapsedTime = now - startTimeRef.current
147
129
  deltaMemoRef.current = now
148
- reqIdRef.current = requestAnimationFrame(() =>
149
- loop(context, width, height, count + 1),
150
- )
130
+ if (animate) {
131
+ reqIdRef.current = requestAnimationFrame(() =>
132
+ loop(context, width, height, count + 1),
133
+ )
134
+ }
151
135
  draw(context, {
152
136
  width: width.current,
153
137
  height: height.current,
@@ -157,7 +141,7 @@ export function AnimationCanvas({
157
141
  fps: 1000 / deltaTime,
158
142
  })
159
143
  },
160
- [draw],
144
+ [draw, animate],
161
145
  )
162
146
 
163
147
  useEffect(() => {
@@ -167,6 +151,18 @@ export function AnimationCanvas({
167
151
  if (!context) throw new Error('Cannot get canvas context.')
168
152
  const dpr = globalThis.devicePixelRatio
169
153
 
154
+ const firstRendering = (width: number, height: number) => {
155
+ setDprConfig(canvas, context, width, height, dpr)
156
+ widthRef.current = width
157
+ heightRef.current = height
158
+
159
+ if (init) init(context, { width, height })
160
+ const now = performance.now()
161
+ deltaMemoRef.current = now
162
+ startTimeRef.current = now
163
+ loop(context, widthRef, heightRef, -1)
164
+ }
165
+
170
166
  if (relativeSize) {
171
167
  const parent = canvas.parentElement
172
168
  if (!parent) throw new Error("Canvas doesn't have a parent element.")
@@ -175,16 +171,14 @@ export function AnimationCanvas({
175
171
 
176
172
  const ro = new ResizeObserver((entries) => {
177
173
  for (const entry of entries) {
178
- // Prevents loss of some context when the canvas is resized
179
- const contextMemo = {} as DrawingContext
180
- for (const prop of drawingState) {
181
- ;(contextMemo[prop] as DrawingStateValue) = context[prop]
182
- }
183
-
184
174
  const w = entry.contentRect.width
185
175
  const h = entry.contentRect.height
186
-
176
+ const contextMemo = {} as DrawingContext
187
177
  if (reduceFlickering && memoCanvas && memoContext) {
178
+ // Prevents loss of some context when the canvas is resized
179
+ for (const prop of drawingState) {
180
+ ;(contextMemo[prop] as DrawingStateValue) = context[prop]
181
+ }
188
182
  memoCanvas.width = w * dpr
189
183
  memoCanvas.height = h * dpr
190
184
  memoContext.scale(1 / dpr, 1 / dpr)
@@ -197,10 +191,6 @@ export function AnimationCanvas({
197
191
  widthRef.current = w
198
192
  heightRef.current = h
199
193
 
200
- for (const prop of drawingState) {
201
- ;(context[prop] as DrawingStateValue) = contextMemo[prop]
202
- }
203
-
204
194
  if (
205
195
  reduceFlickering &&
206
196
  memoContext &&
@@ -208,52 +198,45 @@ export function AnimationCanvas({
208
198
  memoCanvas.width > 0 &&
209
199
  memoCanvas.height > 0
210
200
  ) {
201
+ for (const prop of drawingState) {
202
+ ;(context[prop] as DrawingStateValue) = contextMemo[prop]
203
+ }
204
+
211
205
  context.drawImage(memoContext.canvas, 0, 0)
212
206
  }
207
+
208
+ if (!animate) {
209
+ // re rendering
210
+ loop(context, widthRef, heightRef, -1)
211
+ }
213
212
  }
214
213
  })
215
214
  ro.observe(parent)
216
215
 
217
- const w = parent.clientWidth
218
- const h = parent.clientHeight
219
- setDprConfig(canvas, context, w, h, dpr)
220
-
221
- if (init)
222
- init(context, { width: widthRef.current, height: heightRef.current })
223
- const now = performance.now()
224
- deltaMemoRef.current = now
225
- startTimeRef.current = now
226
- loop(context, widthRef, heightRef, -1)
216
+ const width = parent.clientWidth
217
+ const height = parent.clientHeight
218
+ firstRendering(width, height)
227
219
 
228
220
  return () => {
229
221
  if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current)
230
222
  ro.disconnect()
231
223
  }
232
224
  } else {
233
- const rect = canvas.getBoundingClientRect()
234
- setDprConfig(canvas, context, rect.width, rect.height, dpr)
235
- widthRef.current = rect.width
236
- heightRef.current = rect.height
237
-
238
- if (init)
239
- init(context, { width: widthRef.current, height: heightRef.current })
240
- const now = performance.now()
241
- deltaMemoRef.current = now
242
- startTimeRef.current = now
243
- loop(context, widthRef, heightRef, -1)
225
+ const { width, height } = canvas.getBoundingClientRect()
226
+ firstRendering(width, height)
244
227
 
245
228
  return () => {
246
229
  if (reqIdRef.current) cancelAnimationFrame(reqIdRef.current)
247
230
  }
248
231
  }
249
- }, [loop, init, options, reduceFlickering, relativeSize])
232
+ }, [loop, init, options, reduceFlickering, relativeSize, animate])
250
233
 
251
234
  return (
252
235
  <>
253
236
  <canvas
254
237
  className={clsx('tremolo-animation-canvas', className)}
255
- width={relativeSize ? 0 : width}
256
- height={relativeSize ? 0 : height}
238
+ width={relativeSize ? 0 : _width}
239
+ height={relativeSize ? 0 : _height}
257
240
  ref={canvasRef}
258
241
  onContextMenu={onContextMenu}
259
242
  {...props}
@@ -4,45 +4,76 @@
4
4
  overflow: visible;
5
5
  outline: 0;
6
6
 
7
- &:focus,
8
- &:hover {
9
- > .tremolo-knob-active-line {
10
- color: #4e76e6;
11
- }
7
+ &[aria-readonly='true'] {
8
+ cursor: not-allowed;
9
+ }
10
+ }
12
11
 
13
- > .tremolo-knob-inactive-line {
14
- color: #ddd;
15
- }
12
+ .tremolo-knob-active-line {
13
+ overflow: visible;
14
+ color: oklch(69.2% 0.17548 266.373);
16
15
 
17
- > .tremolo-knob-thumb {
18
- color: #ccc;
19
- }
16
+ :where(.tremolo-knob:focus, .tremolo-knob:hover) & {
17
+ color: var(--tremolo-theme-color);
20
18
  }
21
19
 
22
- &[aria-readonly='true'] {
23
- cursor: not-allowed;
20
+ :where(.tremolo-knob[aria-disabled='true']) & {
21
+ color: oklch(50.5% 0.03307 270.021);
24
22
  }
23
+ }
25
24
 
26
- &[aria-disabled='true'] {
27
- > .tremolo-knob-active-line {
28
- color: #5d6478;
25
+ :where(.dark, [data-theme='dark']) {
26
+ .tremolo-knob-active-line {
27
+ color: var(--tremolo-theme-color);
28
+
29
+ :where(.tremolo-knob:focus, .tremolo-knob:hover) & {
30
+ color: oklch(65.2% 0.17548 266.373);
29
31
  }
30
32
  }
31
33
  }
32
34
 
33
- .tremolo-knob-active-line {
34
- overflow: visible;
35
- color: #7998ec;
35
+ .tremolo-knob-inactive-line {
36
+ color: oklch(95% 0 0);
37
+
38
+ :where(.tremolo-knob:focus, .tremolo-knob:hover) & {
39
+ color: oklch(90% 0 0);
40
+ }
36
41
  }
37
42
 
38
- .tremolo-knob-inactive-line {
39
- color: #eee;
43
+ :where(.dark, [data-theme='dark']) {
44
+ .tremolo-knob-inactive-line {
45
+ color: oklch(41.7% 0 0);
46
+
47
+ :where(.tremolo-knob:focus, .tremolo-knob:hover) & {
48
+ color: oklch(44.59% 0 0);
49
+ }
50
+ }
40
51
  }
41
52
 
42
53
  .tremolo-knob-thumb {
43
- color: #d0d0d0;
54
+ color: oklch(85.7% 0 0);
55
+
56
+ :where(.tremolo-knob:focus, .tremolo-knob:hover) & {
57
+ color: oklch(84.5% 0 0);
58
+ }
59
+ }
60
+
61
+ :where(.dark, [data-theme='dark']) {
62
+ .tremolo-knob-thumb {
63
+ color: oklch(48.6% 0 0);
64
+
65
+ :where(.tremolo-knob:focus, .tremolo-knob:hover) & {
66
+ color: oklch(51.7% 0 0);
67
+ }
68
+ }
44
69
  }
45
70
 
46
71
  .tremolo-knob-thumb-line {
47
- color: #eee;
72
+ color: oklch(94.2% 0 0);
73
+ }
74
+
75
+ :where(.dark, [data-theme='dark']) {
76
+ .tremolo-knob-thumb-line {
77
+ color: oklch(84.2% 0 0);
78
+ }
48
79
  }
@@ -43,8 +43,8 @@ export interface KnobProps {
43
43
  */
44
44
  startValue?: number
45
45
 
46
- // TODO: relative size (Lower priority)
47
- size?: number
46
+ /** width and height */
47
+ size?: number | string
48
48
 
49
49
  /** Whether to apply `{use-select: none}` when dragging */
50
50
  bodyNoSelect?: boolean
@@ -69,15 +69,16 @@ export interface KnobProps {
69
69
  thumb?: string
70
70
  /** color */
71
71
  thumbLine?: string
72
-
73
72
  /** percent (0-100) */
74
73
  thumbSize?: number
75
74
  /** percent (0-100) */
76
75
  thumbLineWeight?: number
77
76
  /** percent (0-100) */
78
77
  thumbLineLength?: number
79
- /** percent (0-100) */
78
+ /** line weight [px] */
80
79
  lineWeight?: number
80
+ /** angle range [degree] */
81
+ angleRange?: number
81
82
 
82
83
  classes?: {
83
84
  activeLine?: string
@@ -125,6 +126,11 @@ export const Knob = forwardRef<KnobMethods, Props>(
125
126
  inactiveLine,
126
127
  thumb,
127
128
  thumbLine,
129
+ thumbSize = 84,
130
+ thumbLineWeight = 6,
131
+ thumbLineLength = 35,
132
+ lineWeight = 6,
133
+ angleRange = 270,
128
134
  onChange,
129
135
  onKeyDown,
130
136
  onPointerDown,
@@ -133,26 +139,18 @@ export const Knob = forwardRef<KnobMethods, Props>(
133
139
  classes,
134
140
  ...props
135
141
  }: Props,
136
- ref,
142
+ forwardedRef,
137
143
  ) => {
138
144
  const valueRef = useRef(0)
139
145
  const elmRef = useRef<HTMLOrSVGElement>(null)
140
146
 
141
- // --- interpret props ---
142
- const padding = 8 // %
143
- const thumbLineWeight = 6 // %
144
- const thumbLineLength = 35 // %
145
- const lineWeight = size * 0.06 // px
146
- const p = normalizeValue(value, min, max, skew)
147
- const s = normalizeValue(startValue, min, max, skew)
148
-
149
147
  // --- internal functions ---
150
148
  const onDrag = useCallback(
151
149
  (_x: number, y: number) => {
152
150
  if (!onChange || readonly) return
153
151
  const v = rawValue(valueRef.current - y / 100, min, max, skew)
154
- const v2 = clamp(stepValue(v, step), min, max)
155
- onChange(v2)
152
+ const clamped = clamp(stepValue(v, step), min, max)
153
+ onChange(clamped)
156
154
  },
157
155
  [max, min, onChange, readonly, skew, step],
158
156
  )
@@ -210,7 +208,7 @@ export const Knob = forwardRef<KnobMethods, Props>(
210
208
  [wheel, onChange, readonly, updateValueByEvent],
211
209
  )
212
210
 
213
- useImperativeHandle(ref, () => {
211
+ useImperativeHandle(forwardedRef, () => {
214
212
  return {
215
213
  focus() {
216
214
  elmRef.current?.focus()
@@ -221,11 +219,17 @@ export const Knob = forwardRef<KnobMethods, Props>(
221
219
  }
222
220
  }, [])
223
221
 
224
- const center = size / 2
225
- const r1 = -135
226
- const r2 = -135 + Math.min(p, s) * 270
227
- const r3 = -135 + Math.max(p, s) * 270
228
- const r4 = 135
222
+ const viewBoxSize = 100
223
+ const center = viewBoxSize / 2
224
+ const padding = (viewBoxSize - clamp(thumbSize, 0, 100)) / 2 // %
225
+ const p = normalizeValue(value, min, max, skew)
226
+ const s = normalizeValue(startValue, min, max, skew)
227
+
228
+ // 時計回りで描画していく
229
+ const r1 = -angleRange / 2 // ロータリー開始位置
230
+ const r2 = r1 + Math.min(p, s) * angleRange // activeLineの開始位置
231
+ const r3 = r1 + Math.max(p, s) * angleRange // activeLineの終了位置
232
+ const r4 = angleRange / 2 // ロータリー終了位置
229
233
  const x1 = center + center * Math.cos(radian(r1 - 90))
230
234
  const y1 = center + center * Math.sin(radian(r1 - 90))
231
235
  const x2 = center + center * Math.cos(radian(r2 - 90))
@@ -237,12 +241,13 @@ export const Knob = forwardRef<KnobMethods, Props>(
237
241
 
238
242
  return (
239
243
  <svg
240
- className={clsx('tremolo-knob', className)}
241
244
  ref={(div) => {
242
245
  elmRef.current = div
243
246
  wheelRefCallback(div)
244
247
  touchMoveRefCallback(div)
245
248
  }}
249
+ className={clsx('tremolo-knob', className)}
250
+ viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
246
251
  width={size}
247
252
  height={size}
248
253
  tabIndex={0}
@@ -268,7 +273,7 @@ export const Knob = forwardRef<KnobMethods, Props>(
268
273
  }}
269
274
  {...props}
270
275
  >
271
- {/* rotary meter */}
276
+ {/* lines */}
272
277
  {startValue > min && (
273
278
  <path
274
279
  className={clsx(
@@ -305,7 +310,7 @@ export const Knob = forwardRef<KnobMethods, Props>(
305
310
  <circle
306
311
  cx="50%"
307
312
  cy="50%"
308
- r={`${50 - padding}%`}
313
+ r={`${thumbSize / 2}%`}
309
314
  fill={thumb || 'currentColor'}
310
315
  />
311
316
  <line
@@ -320,7 +325,7 @@ export const Knob = forwardRef<KnobMethods, Props>(
320
325
  // https://bugs.webkit.org/show_bug.cgi?id=201854
321
326
  // https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/transform-origin#browser_compatibility
322
327
  style={{
323
- transform: `rotate(${-135 + p * 270}deg)`,
328
+ transform: `rotate(${r1 + p * angleRange}deg)`,
324
329
  transformOrigin: '50% 50%',
325
330
  }}
326
331
  />
@@ -16,11 +16,11 @@ import {
16
16
  rawValue,
17
17
  stepValue,
18
18
  } from '@tremolo-ui/functions'
19
- import { parseValue } from '@tremolo-ui/functions/NumberInput'
20
19
 
21
20
  import { useRefCallbackEvent } from '../../hooks/useRefCallbackEvent'
22
21
 
23
22
  import { safeClamp, useNumberInputContext } from './context'
23
+ import { parseValue } from './type'
24
24
 
25
25
  import { NumberInputMethods } from '.'
26
26
 
@@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useRef } from 'react'
2
2
  import { createStore, useStore } from 'zustand'
3
3
 
4
4
  import { clamp } from '@tremolo-ui/functions'
5
- import { parseValue, Units } from '@tremolo-ui/functions/NumberInput'
5
+
6
+ import { parseValue, Units } from './type'
6
7
 
7
8
  type State = {
8
9
  value: string
@@ -72,6 +72,7 @@
72
72
 
73
73
  .tremolo-number-input {
74
74
  display: block;
75
+ color: inherit;
75
76
  width: calc(100% - 20px - var(--stepper-width));
76
77
  background-color: inherit;
77
78
  font: inherit;
@@ -2,10 +2,10 @@ import clsx from 'clsx'
2
2
  import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
3
3
 
4
4
  import { InputEventOption } from '@tremolo-ui/functions'
5
- import { Units } from '@tremolo-ui/functions/NumberInput'
6
5
 
7
6
  import { NumberInputProvider } from './context'
8
7
  import { InternalInput } from './InternalInput'
8
+ import { Units } from './type'
9
9
 
10
10
  /*
11
11
  TODO
@@ -55,7 +55,6 @@ export interface NumberInputProps {
55
55
  keepWithinRange?: boolean
56
56
  clampValueOnBlur?: boolean
57
57
 
58
- // TODO
59
58
  /**
60
59
  * wheel control option
61
60
  */
@@ -126,9 +125,9 @@ export const NumberInput = forwardRef<NumberInputMethods, Props>(
126
125
  children,
127
126
  ...props
128
127
  }: Props,
129
- ref,
128
+ forwardedRef,
130
129
  ) => {
131
- const colors: { [key: string]: string | undefined } = {
130
+ const colors: Record<string, string | undefined> = {
132
131
  '--active-color': activeColor,
133
132
  }
134
133
  return (
@@ -154,7 +153,7 @@ export const NumberInput = forwardRef<NumberInputMethods, Props>(
154
153
  data-variant={variant}
155
154
  >
156
155
  <InternalInput
157
- ref={ref}
156
+ ref={forwardedRef}
158
157
  readonly={readonly}
159
158
  disabled={disabled}
160
159
  selectWithFocus={selectWithFocus}
@@ -178,3 +177,4 @@ export const NumberInput = forwardRef<NumberInputMethods, Props>(
178
177
  export * from './Stepper'
179
178
  export * from './IncrementStepper'
180
179
  export * from './DecrementStepper'
180
+ export { type Units } from './type'
@@ -0,0 +1,58 @@
1
+ /**
2
+ * [unit, scale][]
3
+ * @example
4
+ * [['Hz', 1], ['kHz', 1000]]
5
+ */
6
+ export type Units = [string, number][]
7
+
8
+ export function selectUnit(units: Units, value: number): [string, number] {
9
+ let i = 0
10
+ for (; i < units.length; i++) {
11
+ if (Math.abs(units[i][1]) > Math.abs(value)) break
12
+ }
13
+ return units[Math.max(0, i - 1)]
14
+ }
15
+
16
+ export function parseValue(
17
+ inputString: string,
18
+ units?: string | Units,
19
+ digit?: number,
20
+ ): {
21
+ rawValue: number
22
+ formatValue: string
23
+ unit: string
24
+ } {
25
+ const str = inputString.trim()
26
+ if (!units || typeof units == 'string') {
27
+ const m = str.match(/-?\d+(\.\d+)?/)
28
+ let v = Number(m?.[0] ?? '0')
29
+ v = isNaN(v) ? 0 : v
30
+ return {
31
+ rawValue: v,
32
+ formatValue: (digit != undefined ? v.toFixed(digit) : v) + (units ?? ''),
33
+ unit: units ?? '',
34
+ }
35
+ }
36
+
37
+ const unitList = units.map(([u, _s]) => u)
38
+ const scaleList = units.map(([_u, s]) => s)
39
+ let rawValue = 0
40
+ let unit = unitList[0]
41
+ let formatValue =
42
+ (digit != undefined ? rawValue.toFixed(digit) : rawValue) + unit
43
+ const m = str.match(/^(-?\d+(\.\d+)?)\s*(\w*)$/)
44
+ if (m) {
45
+ rawValue = Number(m[1]) || 0
46
+ const uIndex = unitList.indexOf(m[3])
47
+ rawValue *= uIndex != -1 ? scaleList[uIndex] : 1
48
+ const [u, scale] = selectUnit(units, rawValue)
49
+ const v = rawValue / scale
50
+ formatValue = (digit != undefined ? v.toFixed(digit) : v) + u
51
+ unit = u
52
+ }
53
+ return {
54
+ rawValue: rawValue,
55
+ formatValue: formatValue,
56
+ unit: unit,
57
+ }
58
+ }