@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,17 +1,17 @@
1
1
  import clsx from 'clsx'
2
2
  import { ComponentPropsWithoutRef, CSSProperties, useCallback } from 'react'
3
3
 
4
- import { toFixed, xor, normalizeValue } from '@tremolo-ui/functions'
4
+ import { toFixed, xor } from '@tremolo-ui/functions'
5
5
 
6
6
  import { useSliderContext } from './context'
7
- import { ScaleType } from './type'
7
+ import { MarksType } from './type'
8
8
 
9
- export interface ScaleOptionProps {
9
+ export interface MarksOptionProps {
10
10
  // required
11
11
  value: number
12
12
 
13
13
  // optional
14
- type?: ScaleType
14
+ type?: MarksType
15
15
  /** Display text instead of value. */
16
16
  label?: string
17
17
  /** mark thickness */
@@ -31,7 +31,7 @@ export interface ScaleOptionProps {
31
31
  }
32
32
  }
33
33
 
34
- export function ScaleOption({
34
+ export function MarksOption({
35
35
  value,
36
36
  type = 'mark-number',
37
37
  label,
@@ -44,28 +44,26 @@ export function ScaleOption({
44
44
  className,
45
45
  style,
46
46
  ...props
47
- }: ScaleOptionProps &
48
- Omit<ComponentPropsWithoutRef<'div'>, keyof ScaleOptionProps>) {
47
+ }: MarksOptionProps &
48
+ Omit<ComponentPropsWithoutRef<'div'>, keyof MarksOptionProps>) {
49
49
  const min = useSliderContext((s) => s.min)
50
50
  const max = useSliderContext((s) => s.max)
51
- const skew = useSliderContext((s) => s.skew)
51
+ const scale = useSliderContext((s) => s.scale)
52
52
  const vertical = useSliderContext((s) => s.vertical)
53
53
  const reverse = useSliderContext((s) => s.reverse)
54
54
 
55
55
  const calcPercent = useCallback(
56
- (rawValue: number) => {
57
- return toFixed(
58
- xor(vertical, reverse)
59
- ? 100 - normalizeValue(rawValue, min, max, skew) * 100
60
- : normalizeValue(rawValue, min, max, skew) * 100,
61
- )
56
+ (value: number) => {
57
+ // The marks have to sit on the same curve the thumb runs along.
58
+ const percent = scale.normalize(value, min, max) * 100
59
+ return toFixed(xor(vertical, reverse) ? 100 - percent : percent)
62
60
  },
63
- [vertical, reverse, max, min, skew],
61
+ [vertical, reverse, max, min, scale],
64
62
  )
65
63
 
66
64
  return (
67
65
  <div
68
- className={clsx('tremolo-slider-scale-option', className)}
66
+ className={clsx('tremolo-slider-marks-option', className)}
69
67
  style={{
70
68
  left: !vertical ? `${calcPercent(value)}%` : undefined,
71
69
  top: vertical ? `${calcPercent(value)}%` : undefined,
@@ -76,7 +74,7 @@ export function ScaleOption({
76
74
  >
77
75
  {type != 'number' && (
78
76
  <div
79
- className={clsx('tremolo-slider-scale-option-mark', classes?.mark)}
77
+ className={clsx('tremolo-slider-marks-option-mark', classes?.mark)}
80
78
  style={{
81
79
  width: !vertical ? thickness : length,
82
80
  height: vertical ? thickness : length,
@@ -89,7 +87,7 @@ export function ScaleOption({
89
87
  )}
90
88
  {type != 'mark' && (
91
89
  <div
92
- className={clsx('tremolo-slider-scale-option-label', classes?.label)}
90
+ className={clsx('tremolo-slider-marks-option-label', classes?.label)}
93
91
  style={{
94
92
  width: labelWidth,
95
93
  ...styles?.label,
@@ -1,8 +1,8 @@
1
1
  import clsx from 'clsx'
2
2
  import {
3
3
  CSSProperties,
4
- forwardRef,
5
4
  ReactNode,
5
+ Ref,
6
6
  useImperativeHandle,
7
7
  useRef,
8
8
  } from 'react'
@@ -10,18 +10,16 @@ import {
10
10
  import { useSliderContext } from './context'
11
11
 
12
12
  export interface SliderThumbProps {
13
- size?: number | string
14
- width?: number | string
15
- height?: number | string
16
-
13
+ /**
14
+ * Size comes from the `--thumb-size` CSS variable on `Slider.Root`, so that
15
+ * the root can reserve the matching amount of space around the track.
16
+ */
17
17
  color?: string
18
18
 
19
19
  className?: string
20
20
  style?: CSSProperties
21
21
  children?: ReactNode
22
-
23
- /** @internal */
24
- __percent?: number
22
+ ref?: Ref<SliderThumbMethods>
25
23
  }
26
24
 
27
25
  export interface SliderThumbMethods {
@@ -29,66 +27,55 @@ export interface SliderThumbMethods {
29
27
  blur: () => void
30
28
  }
31
29
 
32
- export const defaultThumbSize = 22
30
+ export function Thumb({
31
+ color,
32
+ children,
33
+ className,
34
+ style,
35
+ ref,
36
+ }: SliderThumbProps) {
37
+ const elementRef = useRef<HTMLDivElement>(null)
38
+ const { vertical, disabled, readonly, percent, thumbRef } = useSliderContext()
33
39
 
34
- export const Thumb = forwardRef<SliderThumbMethods, SliderThumbProps>(
35
- (
36
- {
37
- size,
38
- width = defaultThumbSize,
39
- height = defaultThumbSize,
40
- color,
41
- children,
42
- className,
43
- style,
44
- __percent,
45
- }: SliderThumbProps,
46
- ref,
47
- ) => {
48
- const wrapperRef = useRef<HTMLDivElement>(null)
49
- const vertical = useSliderContext((s) => s.vertical)
50
- const disabled = useSliderContext((s) => s.disabled)
51
- const readonly = useSliderContext((s) => s.readonly)
40
+ const methods = () => ({
41
+ focus() {
42
+ elementRef.current?.focus()
43
+ },
44
+ blur() {
45
+ elementRef.current?.blur()
46
+ },
47
+ })
52
48
 
53
- useImperativeHandle(ref, () => {
54
- return {
55
- focus() {
56
- wrapperRef.current?.focus()
57
- },
58
- blur() {
59
- wrapperRef.current?.blur()
60
- },
61
- }
62
- }, [])
49
+ useImperativeHandle(ref, methods, [])
50
+ // Root focuses the thumb when a drag starts, wherever the user placed it.
63
51
 
64
- return (
65
- <div
66
- className="tremolo-slider-thumb-wrapper"
67
- style={{
68
- top: vertical ? `${__percent}%` : '50%',
69
- left: !vertical ? `${__percent}%` : '50%',
70
- }}
71
- >
72
- {children ? (
73
- children
74
- ) : (
75
- // default slider thumb
76
- <div
77
- ref={wrapperRef}
78
- className={clsx('tremolo-slider-thumb', className)}
79
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
80
- tabIndex={0}
81
- aria-disabled={disabled}
82
- aria-readonly={readonly}
83
- style={{
84
- ...{ '--color': color },
85
- width: size ?? width,
86
- height: size ?? height,
87
- ...style,
88
- }}
89
- ></div>
90
- )}
91
- </div>
92
- )
93
- },
94
- )
52
+ useImperativeHandle(thumbRef, methods, [])
53
+
54
+ return (
55
+ <div
56
+ className="tremolo-slider-thumb-wrapper"
57
+ style={{
58
+ top: vertical ? `${percent}%` : '50%',
59
+ left: !vertical ? `${percent}%` : '50%',
60
+ }}
61
+ >
62
+ {children ? (
63
+ children
64
+ ) : (
65
+ // default slider thumb
66
+ <div
67
+ ref={elementRef}
68
+ className={clsx('tremolo-slider-thumb', className)}
69
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
70
+ tabIndex={0}
71
+ aria-disabled={disabled}
72
+ aria-readonly={readonly}
73
+ style={{
74
+ ...{ '--color': color },
75
+ ...style,
76
+ }}
77
+ ></div>
78
+ )}
79
+ </div>
80
+ )
81
+ }
@@ -1,8 +1,10 @@
1
1
  import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, CSSProperties, ReactElement } from 'react'
2
+ import { ComponentPropsWithoutRef, CSSProperties, ReactNode, Ref } from 'react'
3
3
 
4
4
  import { styleHelper, xor } from '@tremolo-ui/functions'
5
5
 
6
+ import { useComposedRefs } from '../_util/composeRefs'
7
+
6
8
  import { useSliderContext } from './context'
7
9
 
8
10
  export const defaultLength = 140
@@ -19,12 +21,9 @@ export interface SliderTrackProps {
19
21
 
20
22
  className?: string
21
23
  style?: CSSProperties
22
- children?: ReactElement
23
-
24
- /** @internal */
25
- __thumb?: ReactElement
26
- /** @internal */
27
- __percent?: number
24
+ /** `<Slider.Thumb />` goes here. */
25
+ children?: ReactNode
26
+ ref?: Ref<HTMLDivElement>
28
27
  }
29
28
 
30
29
  export function Track({
@@ -36,14 +35,15 @@ export function Track({
36
35
  className,
37
36
  style,
38
37
  defaultStyle = true,
39
- __thumb,
40
- __percent = 0,
38
+ ref,
41
39
  ...props
42
40
  }: SliderTrackProps &
43
41
  Omit<ComponentPropsWithoutRef<'div'>, keyof SliderTrackProps>) {
44
- const vertical = useSliderContext((s) => s.vertical)
45
- const reverse = useSliderContext((s) => s.reverse)
46
- const disabled = useSliderContext((s) => s.disabled)
42
+ const { vertical, reverse, disabled, percent, trackRef } = useSliderContext()
43
+
44
+ // The track is what the pointer position is normalized against, so the
45
+ // context ref is composed with any ref the caller passed.
46
+ const composedRef = useComposedRefs<HTMLDivElement>(ref, trackRef)
47
47
 
48
48
  const direction = vertical ? 'bottom' : 'right'
49
49
  const colors = {
@@ -53,6 +53,7 @@ export function Track({
53
53
 
54
54
  return (
55
55
  <div
56
+ ref={composedRef}
56
57
  className={clsx('tremolo-slider-track', className)}
57
58
  aria-disabled={disabled}
58
59
  data-vertical={vertical}
@@ -62,8 +63,8 @@ export function Track({
62
63
  : {
63
64
  ...colors,
64
65
  background: xor(vertical, reverse)
65
- ? `linear-gradient(to ${direction}, var(--inactive) ${__percent}%, var(--active) ${__percent}%)`
66
- : `linear-gradient(to ${direction}, var(--active) ${__percent}%, var(--inactive) ${__percent}%)`,
66
+ ? `linear-gradient(to ${direction}, var(--inactive) ${percent}%, var(--active) ${percent}%)`
67
+ : `linear-gradient(to ${direction}, var(--active) ${percent}%, var(--inactive) ${percent}%)`,
67
68
  borderRadius: styleHelper(thickness!, '/', 2),
68
69
  width: !vertical ? length : thickness,
69
70
  height: vertical ? length : thickness,
@@ -73,7 +74,6 @@ export function Track({
73
74
  {...props}
74
75
  >
75
76
  {children}
76
- {__thumb}
77
77
  </div>
78
78
  )
79
79
  }
@@ -1,64 +1,49 @@
1
- import { createContext, useContext, useEffect, useRef } from 'react'
2
- import { createStore, useStore } from 'zustand'
1
+ import { createContext, RefObject, useContext } from 'react'
3
2
 
4
- type State = {
3
+ import type { Scale } from '@tremolo-ui/functions'
4
+
5
+ import type { SliderThumbMethods } from './Thumb'
6
+
7
+ export type SliderContextValue = {
8
+ value: number
5
9
  min: number
6
10
  max: number
7
11
  step: number
8
- skew: number
12
+ /** How the value is distributed across the travel. */
13
+ scale: Scale
9
14
  vertical: boolean
10
15
  reverse: boolean
11
16
  disabled: boolean
12
17
  readonly: boolean
13
- }
14
-
15
- type SliderStore = ReturnType<typeof createSliderStore>
16
-
17
- const createSliderStore = (initProps?: Partial<State>) => {
18
- const DEFAULT_PROPS: State = {
19
- min: 0,
20
- max: 0,
21
- step: 0,
22
- skew: 0,
23
- vertical: false,
24
- reverse: false,
25
- disabled: false,
26
- readonly: false,
27
- }
28
-
29
- return createStore<State>()(() => ({
30
- ...DEFAULT_PROPS,
31
- ...initProps,
32
- }))
33
- }
34
-
35
- const SliderContext = createContext<SliderStore | null>(null)
36
-
37
- type SliderProviderProps = React.PropsWithChildren<Partial<State>>
38
-
39
- export function SliderProvider({ children, ...props }: SliderProviderProps) {
40
- const storeRef = useRef<SliderStore>(null)
41
- if (!storeRef.current) {
42
- storeRef.current = createSliderStore(props)
43
- }
44
18
 
45
- useEffect(() => {
46
- if (storeRef.current) {
47
- storeRef.current.setState(props)
48
- } else {
49
- storeRef.current = createSliderStore(props)
50
- }
51
- }, [props])
19
+ /**
20
+ * Position of the thumb, 0-100, already accounting for the display direction
21
+ * implied by `vertical` and `reverse`.
22
+ */
23
+ percent: number
52
24
 
53
- return (
54
- <SliderContext.Provider value={storeRef.current}>
55
- {children}
56
- </SliderContext.Provider>
57
- )
25
+ /** `Track` registers its element here; `Root` normalizes the pointer against it. */
26
+ trackRef: RefObject<HTMLDivElement | null>
27
+ /** `Thumb` registers itself here; `Root` focuses it when a drag starts. */
28
+ thumbRef: RefObject<SliderThumbMethods | null>
58
29
  }
59
30
 
60
- export function useSliderContext<T>(selector: (state: State) => T): T {
61
- const store = useContext(SliderContext)
62
- if (!store) throw new Error('Missing SliderContext.Provider in the tree')
63
- return useStore(store, selector)
31
+ const SliderContext = createContext<SliderContextValue | null>(null)
32
+
33
+ export const SliderProvider = SliderContext.Provider
34
+
35
+ /**
36
+ * Everything here is derived during render, so there is no state to keep in
37
+ * sync: `value` comes from the props of `Root` and the rest follows from it.
38
+ */
39
+ export function useSliderContext(): SliderContextValue
40
+ export function useSliderContext<T>(
41
+ selector: (state: SliderContextValue) => T,
42
+ ): T
43
+ export function useSliderContext<T>(
44
+ selector?: (state: SliderContextValue) => T,
45
+ ) {
46
+ const context = useContext(SliderContext)
47
+ if (!context) throw new Error('Missing SliderContext.Provider in the tree')
48
+ return selector ? selector(context) : context
64
49
  }
@@ -1,9 +1,19 @@
1
1
  .tremolo-slider {
2
- display: inline-block;
2
+ /* Half the thumb size. The thumb overhangs the ends of the track, so the
3
+ slider reserves room for it. Override this when using a different size. */
4
+ --thumb-size: 22px;
5
+
6
+ display: inline-flex;
7
+ flex-direction: column;
8
+ margin: calc(var(--thumb-size) / 2);
3
9
  cursor: pointer;
4
10
  outline: 0;
5
11
  -webkit-tap-highlight-color: transparent;
6
12
 
13
+ &[data-vertical='true'] {
14
+ flex-direction: row;
15
+ }
16
+
7
17
  &[aria-readonly='true'] {
8
18
  cursor: not-allowed;
9
19
  }
@@ -18,6 +28,9 @@
18
28
  .tremolo-slider-thumb {
19
29
  --color: oklch(59.5% 0.17548 266.373);
20
30
 
31
+ width: var(--thumb-size);
32
+ height: var(--thumb-size);
33
+
21
34
  background: var(--color);
22
35
  border-radius: 50%;
23
36
  outline: none;
@@ -57,12 +70,12 @@
57
70
  }
58
71
  }
59
72
 
60
- .tremolo-slider-scale {
73
+ .tremolo-slider-marks {
61
74
  display: block;
62
75
  position: relative;
63
76
  }
64
77
 
65
- .tremolo-slider-scale-option {
78
+ .tremolo-slider-marks-option {
66
79
  display: flex;
67
80
  color: oklch(25.197% 0.00003 271.152);
68
81
  flex-direction: column;
@@ -78,11 +91,11 @@
78
91
  }
79
92
  }
80
93
 
81
- .tremolo-slider-scale-option-mark {
94
+ .tremolo-slider-marks-option-mark {
82
95
  background-color: currentColor;
83
96
  }
84
97
 
85
- .tremolo-slider-scale-option-label {
98
+ .tremolo-slider-marks-option-label {
86
99
  text-align: right;
87
100
  }
88
101