@tremolo-ui/react 0.7.0 → 0.9.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 (55) hide show
  1. package/dist/index.cjs +666 -1171
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +316 -315
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +316 -315
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +643 -1123
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/components/AnimationCanvas/index.tsx +4 -18
  11. package/src/components/DropZone/index.tsx +2 -1
  12. package/src/components/FileInput/index.tsx +11 -13
  13. package/src/components/Knob/ActiveLine.tsx +5 -3
  14. package/src/components/Knob/InactiveLine.tsx +6 -4
  15. package/src/components/Knob/SVGRoot.tsx +3 -3
  16. package/src/components/Knob/Thumb.tsx +21 -20
  17. package/src/components/Knob/context.tsx +3 -74
  18. package/src/components/Knob/index.tsx +99 -52
  19. package/src/components/NumberInput/InputField.tsx +20 -33
  20. package/src/components/NumberInput/Stepper.tsx +76 -100
  21. package/src/components/NumberInput/StepperButton.tsx +15 -8
  22. package/src/components/NumberInput/context.tsx +26 -3
  23. package/src/components/NumberInput/index.tsx +175 -91
  24. package/src/components/Piano/index.tsx +54 -144
  25. package/src/components/PointsEditor/Container.tsx +15 -26
  26. package/src/components/PointsEditor/Point.tsx +94 -110
  27. package/src/components/PointsEditor/context.tsx +8 -60
  28. package/src/components/PointsEditor/index.tsx +41 -324
  29. package/src/components/Slider/Marks.tsx +5 -6
  30. package/src/components/Slider/MarksOption.tsx +9 -19
  31. package/src/components/Slider/Thumb.tsx +20 -11
  32. package/src/components/Slider/Track.tsx +16 -13
  33. package/src/components/Slider/context.tsx +3 -3
  34. package/src/components/Slider/index.tsx +110 -72
  35. package/src/components/XYPad/Area.tsx +27 -49
  36. package/src/components/XYPad/Thumb.tsx +20 -8
  37. package/src/components/XYPad/context.tsx +2 -4
  38. package/src/components/XYPad/index.tsx +102 -83
  39. package/src/components/_util/VisuallyHiddenRangeInput.tsx +1 -1
  40. package/src/hooks/_internal/useChangeGesture.ts +93 -0
  41. package/src/hooks/_internal/useCheckSteps.ts +9 -146
  42. package/src/hooks/useAnimationFrame.ts +37 -15
  43. package/src/hooks/useEventListener.ts +93 -22
  44. package/src/hooks/useLongPress.ts +35 -51
  45. package/src/hooks/useMIDIAccess.ts +0 -8
  46. package/src/hooks/useMIDIInput.ts +0 -3
  47. package/src/hooks/useWheel.ts +4 -4
  48. package/src/index.ts +11 -32
  49. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  50. package/src/components/Slider/type.ts +0 -46
  51. package/src/components/_util/css-length.ts +0 -13
  52. package/src/components/_util/decimal-digits.ts +0 -23
  53. package/src/components/_util/visually-hidden.ts +0 -25
  54. package/src/hooks/useInterval.ts +0 -27
  55. package/src/input-event.ts +0 -32
@@ -3,7 +3,7 @@ import {
3
3
  CSSProperties,
4
4
  forwardRef,
5
5
  ReactNode,
6
- useCallback,
6
+ Ref,
7
7
  useEffect,
8
8
  useImperativeHandle,
9
9
  useMemo,
@@ -12,43 +12,23 @@ import {
12
12
  } from 'react'
13
13
 
14
14
  import {
15
- blackKeyWidth,
16
15
  createPianoInput,
17
- getNoteRangeArray,
16
+ type KeyboardShortcuts,
17
+ type KeyboardShortcutsScope,
18
18
  notePosition,
19
- pianoWidth,
20
19
  type NoteRange,
21
20
  type PianoInputInstance,
22
21
  type PianoLayout,
23
22
  } from '@tremolo-ui/dom'
23
+ import {
24
+ blackKeyWidth,
25
+ fitWhiteKeyWidth,
26
+ getNoteRangeArray,
27
+ pianoWidth,
28
+ } from '@tremolo-ui/dom/internal'
24
29
  import { isWhiteKey, noteKey } from '@tremolo-ui/functions'
25
30
 
26
- import { useEventListener } from '../../hooks/useEventListener'
27
- import { cssLength } from '../_util/css-length'
28
-
29
- import { KeyboardShortcuts } from './keyboard-shortcuts'
30
-
31
- import type { CSSVariables } from '../../css-variables'
32
-
33
- type KeyboardShortcutsScope = 'root' | 'window'
34
-
35
- function isEditableTarget(target: EventTarget | null) {
36
- if (!(target instanceof HTMLElement)) return false
37
- if (target.matches('input, textarea, select')) return true
38
-
39
- for (let element: HTMLElement | null = target; element;) {
40
- const contentEditable = element.getAttribute('contenteditable')
41
- if (contentEditable !== null)
42
- return contentEditable.toLowerCase() !== 'false'
43
- element = element.parentElement
44
- }
45
-
46
- return false
47
- }
48
-
49
- function shortcutKey(event: KeyboardEvent) {
50
- return event.code || event.key
51
- }
31
+ import { useComposedRefs } from '../../compose-refs'
52
32
 
53
33
  /**
54
34
  * What {@link PianoProps.keyProps} may return for one key.
@@ -57,10 +37,8 @@ function shortcutKey(event: KeyboardEvent) {
57
37
  * JSX syntax, not on an object type, and selecting on one is the usual way to
58
38
  * mark a key out.
59
39
  */
60
- export type KeyAttributes = Omit<ComponentPropsWithoutRef<'div'>, 'style'> & {
61
- style?: CSSProperties &
62
- CSSVariables<'color' | 'bg' | 'active-color' | 'active-bg'>
63
- } & Record<`data-${string}`, string | number | boolean | undefined>
40
+ export type KeyAttributes = ComponentPropsWithoutRef<'div'> &
41
+ Record<`data-${string}`, string | number | boolean | undefined>
64
42
 
65
43
  /** What a key is, when {@link PianoProps.label} or `keyProps` is asked about it. */
66
44
  export interface KeyState {
@@ -151,16 +129,9 @@ export interface PianoProps {
151
129
  */
152
130
  blackKeyHeightRatio?: number
153
131
 
154
- /**
155
- * Height of the keyboard. Sets `--height`; when it is omitted, the height
156
- * the theme gives stands, and the theme can tell `resizable` apart through
157
- * `data-resizable`.
158
- */
159
- height?: number | string
160
-
161
132
  /**
162
133
  * What to draw inside a key. `''`, `null` and `undefined` leave it bare, so
163
- * a layout with gaps — {@link SHORTCUTS.HOME_ROW_NATURAL}, say — needs no
134
+ * a layout with gaps — `SHORTCUTS.HOME_ROW_NATURAL`, say — needs no
164
135
  * special casing.
165
136
  */
166
137
  label?: (note: number, state: KeyState) => ReactNode
@@ -190,7 +161,11 @@ export interface PianoProps {
190
161
  /** Called once everything holding a note has let go of it. */
191
162
  onStopNote?: (note: number) => void
192
163
 
193
- style?: CSSProperties & CSSVariables<'height'>
164
+ /**
165
+ * Receives `playNote` and `stopNote`, which press and release a key as the
166
+ * pointer or the keyboard would. `ref` reaches the root element itself.
167
+ */
168
+ actionsRef?: Ref<PianoMethods>
194
169
  }
195
170
 
196
171
  export interface PianoMethods {
@@ -201,7 +176,7 @@ export interface PianoMethods {
201
176
  type Props = PianoProps &
202
177
  Omit<ComponentPropsWithoutRef<'div'>, keyof PianoProps>
203
178
 
204
- export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
179
+ export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
205
180
  function Root(
206
181
  {
207
182
  noteRange,
@@ -214,7 +189,6 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
214
189
  keyGap = 1,
215
190
  blackKeyWidthRatio = 0.65,
216
191
  blackKeyHeightRatio = 0.6,
217
- height,
218
192
  style,
219
193
  className,
220
194
  label,
@@ -222,6 +196,7 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
222
196
  classes,
223
197
  onPlayNote,
224
198
  onStopNote,
199
+ actionsRef,
225
200
  ...props
226
201
  },
227
202
  forwardedRef,
@@ -234,10 +209,6 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
234
209
  const [resizedKeyWidth, setResizedKeyWidth] = useState(whiteKeyWidth)
235
210
 
236
211
  const notes = useMemo(() => getNoteRangeArray(noteRange), [noteRange])
237
- const whiteKeyCount = useMemo(
238
- () => notes.filter(isWhiteKey).length,
239
- [notes],
240
- )
241
212
 
242
213
  const layout: PianoLayout = useMemo(
243
214
  () => ({
@@ -264,21 +235,12 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
264
235
  layout,
265
236
  glissando,
266
237
  midiMax,
238
+ keyboardShortcuts,
239
+ keyboardShortcutsScope,
267
240
  onPlayNote,
268
241
  onStopNote,
269
242
  })
270
243
  const instanceRef = useRef<PianoInputInstance | null>(null)
271
- const shortcutNotes = useRef(
272
- new Map<string, { note: number; source: string }>(),
273
- )
274
-
275
- const releaseShortcutNotes = useCallback(() => {
276
- for (const { note, source } of shortcutNotes.current.values()) {
277
- instanceRef.current?.noteOff(note, { source })
278
- }
279
- shortcutNotes.current.clear()
280
- }, [])
281
-
282
244
  useEffect(() => {
283
245
  if (!node) return
284
246
 
@@ -286,6 +248,8 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
286
248
  layout: latest.current.layout,
287
249
  glissando: latest.current.glissando,
288
250
  midiMax: latest.current.midiMax,
251
+ keyboardShortcuts: latest.current.keyboardShortcuts,
252
+ keyboardShortcutsScope: latest.current.keyboardShortcutsScope,
289
253
  onPlayNote: (note, velocity) =>
290
254
  latest.current.onPlayNote?.(note, velocity),
291
255
  onStopNote: (note) => latest.current.onStopNote?.(note),
@@ -304,10 +268,27 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
304
268
 
305
269
  // Runs after every render.
306
270
  useEffect(() => {
307
- latest.current = { layout, glissando, midiMax, onPlayNote, onStopNote }
308
- instanceRef.current?.update({ layout, glissando, midiMax })
271
+ latest.current = {
272
+ layout,
273
+ glissando,
274
+ midiMax,
275
+ keyboardShortcuts,
276
+ keyboardShortcutsScope,
277
+ onPlayNote,
278
+ onStopNote,
279
+ }
280
+ instanceRef.current?.update({
281
+ layout,
282
+ glissando,
283
+ midiMax,
284
+ keyboardShortcuts,
285
+ keyboardShortcutsScope,
286
+ })
309
287
  })
310
288
 
289
+ // By value, so that a range written inline does not reconnect the
290
+ // observer on every render.
291
+ const { first, last } = noteRange
311
292
  useEffect(() => {
312
293
  if (!resizable || !node) return
313
294
  const parent = node.parentElement
@@ -315,92 +296,24 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
315
296
 
316
297
  const resizeObserver = new ResizeObserver(() => {
317
298
  setResizedKeyWidth(
318
- node.clientWidth / Math.max(whiteKeyCount, 1) - keyGap,
299
+ fitWhiteKeyWidth(node.clientWidth, {
300
+ noteRange: { first, last },
301
+ keyGap,
302
+ blackKeyWidthRatio,
303
+ }),
319
304
  )
320
305
  })
321
306
  resizeObserver.observe(parent)
322
307
  return () => resizeObserver.disconnect()
323
- }, [resizable, node, whiteKeyCount, keyGap])
324
-
325
- const shortcutKeys = keyboardShortcuts?.keys
326
- const hasShortcuts = shortcutKeys !== undefined
327
- // What the held keys were started against, as a value rather than the
328
- // identity of the array: a caller who writes the keys inline hands over a
329
- // new array on every render, and releasing on that would stop a note as
330
- // soon as anything else re-renders — including the state change that
331
- // playing the note caused.
332
- const shortcutMapping = shortcutKeys?.join('\u0000')
333
-
334
- useEffect(
335
- () => releaseShortcutNotes,
336
- [
337
- releaseShortcutNotes,
338
- shortcutMapping,
339
- keyboardShortcutsScope,
340
- noteRange.first,
341
- noteRange.last,
342
- ],
343
- )
344
-
345
- /** The note a shortcut key plays, or null when it has none. */
346
- function shortcutNote(key: string) {
347
- if (!shortcutKeys || key === '') return null
348
- const index = shortcutKeys.indexOf(key)
349
- const note = noteRange.first + index
350
- return index === -1 || note > noteRange.last ? null : note
351
- }
352
-
353
- const shortcutTarget = useMemo(
354
- () => () => {
355
- if (!hasShortcuts) return null
356
- return keyboardShortcutsScope === 'window' ? globalThis.window : node
357
- },
358
- [hasShortcuts, keyboardShortcutsScope, node],
359
- )
360
-
361
- const focusOutTarget = useMemo(
362
- () => () =>
363
- hasShortcuts && keyboardShortcutsScope === 'root' ? node : null,
364
- [hasShortcuts, keyboardShortcutsScope, node],
365
- )
308
+ }, [resizable, node, first, last, keyGap, blackKeyWidthRatio])
366
309
 
367
- const windowBlurTarget = useMemo(
368
- () => () => (hasShortcuts ? globalThis.window : null),
369
- [hasShortcuts],
310
+ const rootRefCallback = useComposedRefs<HTMLDivElement>(
311
+ forwardedRef,
312
+ setNode,
370
313
  )
371
314
 
372
- useEventListener(shortcutTarget, 'keydown', (e) => {
373
- const key = shortcutKey(e)
374
- if (e.repeat || shortcutNotes.current.has(key)) return
375
- if (isEditableTarget(e.target)) return
376
-
377
- const note = shortcutNote(e.key)
378
- if (note === null) return
379
-
380
- const source = `keyboard:${key}`
381
- shortcutNotes.current.set(key, { note, source })
382
- instanceRef.current?.noteOn(note, { source })
383
- })
384
-
385
- useEventListener(shortcutTarget, 'keyup', (e) => {
386
- const key = shortcutKey(e)
387
- const shortcut = shortcutNotes.current.get(key)
388
- if (!shortcut) return
389
-
390
- shortcutNotes.current.delete(key)
391
- instanceRef.current?.noteOff(shortcut.note, { source: shortcut.source })
392
- })
393
-
394
- useEventListener(focusOutTarget, 'focusout', (e) => {
395
- if (e.relatedTarget instanceof Node && node?.contains(e.relatedTarget))
396
- return
397
- releaseShortcutNotes()
398
- })
399
-
400
- useEventListener(windowBlurTarget, 'blur', releaseShortcutNotes)
401
-
402
315
  useImperativeHandle(
403
- forwardedRef,
316
+ actionsRef,
404
317
  () => ({
405
318
  playNote: (note, velocity) =>
406
319
  instanceRef.current?.noteOn(note, { source: 'api', velocity }),
@@ -412,7 +325,7 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
412
325
 
413
326
  return (
414
327
  <div
415
- ref={setNode}
328
+ ref={rootRefCallback}
416
329
  className={className}
417
330
  data-resizable={resizable ? '' : undefined}
418
331
  role="group"
@@ -423,7 +336,6 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
423
336
  {
424
337
  // Computed from the layout rather than chosen, so it stays inline.
425
338
  width: resizable ? '100%' : pianoWidth(layout),
426
- '--height': cssLength(height),
427
339
  // The keys inside are placed against this box.
428
340
  position: 'relative',
429
341
  ...style,
@@ -492,5 +404,3 @@ export const Root = /* @__PURE__ */ forwardRef<PianoMethods, Props>(
492
404
  * Customizable piano component.
493
405
  */
494
406
  export const Piano = { Root }
495
-
496
- export { type KeyboardShortcuts, SHORTCUTS } from './keyboard-shortcuts'
@@ -1,40 +1,33 @@
1
1
  import { ComponentPropsWithoutRef, forwardRef } from 'react'
2
2
 
3
+ import { POINT_AXIS, wheelMove } from '@tremolo-ui/dom/internal'
4
+
3
5
  import { useComposedRefs } from '../../compose-refs'
4
6
  import { useDragValue } from '../../hooks/useDragValue'
5
7
  import { useWheel } from '../../hooks/useWheel'
6
8
  import { Placement } from '../_util/Placement'
7
9
 
8
10
  import { usePointsEditorContext } from './context'
9
- import { AXIS } from './Point'
10
11
 
11
12
  type Props = ComponentPropsWithoutRef<'div'>
12
13
 
13
14
  export const Container = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
14
15
  function Container({ children, className, style, ...props }, forwardedRef) {
15
- const {
16
- containerRef,
17
- disabled,
18
- selectable,
19
- isPointElement,
20
- nudgeFocusedPoint,
21
- beginSelectionBox,
22
- moveSelectionBox,
23
- endSelectionBox,
24
- } = usePointsEditorContext()
16
+ const { containerRef, disabled, selectable, editor } =
17
+ usePointsEditorContext()
25
18
  const { refCallback: dragRefCallback } = useDragValue<HTMLDivElement>({
26
- axis: AXIS,
19
+ axis: POINT_AXIS,
27
20
  baseElementRef: containerRef,
28
21
  // A press that landed on a point belongs to that point. Declining here
29
22
  // rather than in onDragStart matters: by then the container would already
30
23
  // have taken the pointer capture away from the point.
31
24
  shouldStart: (event) =>
32
- !disabled && !isPointElement(event.target as Element | null),
25
+ !disabled && !editor.isPointElement(event.target as Element | null),
33
26
  onDragStart: ([x, y], state) => {
34
- beginSelectionBox({ x, y }, state.event)
27
+ editor.beginSelectionBox({ x, y }, state.event)
35
28
  },
36
- onChange: ([x, y]) => moveSelectionBox({ x, y }),
37
- onDragEnd: endSelectionBox,
29
+ onChange: ([x, y]) => editor.moveSelectionBox({ x, y }),
30
+ onDragEnd: () => editor.endSelectionBox(),
38
31
  })
39
32
 
40
33
  // One listener for the whole editor rather than one per point: a wheel
@@ -44,16 +37,12 @@ export const Container = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
44
37
  useWheel(
45
38
  (event) => {
46
39
  // Scrolling up moves the point towards y = 0; shift switches to x.
47
- // Browsers turn shift+wheel into horizontal scrolling: `deltaY` comes
48
- // out empty and `deltaX` carries the movement. Reading whichever axis
49
- // moved keeps shift working as the x-axis modifier — and picks up a
50
- // trackpad's own horizontal gesture, which never had a modifier.
51
- const horizontal = event.deltaX !== 0
52
- const delta = horizontal ? event.deltaX : event.deltaY
53
- if (delta === 0) return
54
- const axis = horizontal || event.shiftKey ? 'x' : 'y'
55
- const direction = delta < 0 ? -1 : 1
56
- if (nudgeFocusedPoint(axis, direction, event)) event.preventDefault()
40
+ const move = wheelMove(event)
41
+ if (!move) return
42
+ const axis = move.axis === 0 ? 'x' : 'y'
43
+ if (editor.nudgeFocusedPoint(axis, move.direction, event)) {
44
+ event.preventDefault()
45
+ }
57
46
  },
58
47
  { target: containerRef },
59
48
  )