@tremolo-ui/react 0.3.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 (64) hide show
  1. package/dist/index.cjs +1162 -1554
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +58 -93
  4. package/dist/index.d.cts +592 -278
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +592 -278
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1163 -1555
  9. package/dist/index.js.map +1 -1
  10. package/package.json +12 -13
  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 +95 -83
  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 +117 -52
  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 +136 -170
  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 +193 -250
  47. package/src/hooks/useDrag.ts +41 -76
  48. package/src/hooks/useDragValue.ts +149 -0
  49. package/src/hooks/useEventListener.ts +1 -1
  50. package/src/hooks/useWheel.ts +51 -0
  51. package/src/index.ts +15 -13
  52. package/src/components/AnimationCanvas/canvas.ts +0 -46
  53. package/src/components/DragObserver/index.tsx +0 -58
  54. package/src/components/NumberInput/InternalInput.tsx +0 -221
  55. package/src/components/NumberInput/type.ts +0 -58
  56. package/src/components/Piano/KeyLabel.tsx +0 -51
  57. package/src/components/Piano/context.tsx +0 -73
  58. package/src/components/Piano/key.tsx +0 -205
  59. package/src/components/WheelObserver/index.tsx +0 -48
  60. package/src/components/_util/type.ts +0 -1
  61. package/src/hooks/useDragWithElement.ts +0 -72
  62. package/src/hooks/usePianoDrag.ts +0 -75
  63. package/src/hooks/useRefCallbackEvent.ts +0 -34
  64. /package/src/hooks/{useCallbackRef.ts → _internal/useCallbackRef.ts} +0 -0
@@ -1,162 +1,90 @@
1
- import { createContext, useContext, useEffect, useRef } from 'react'
2
- import { createStore, useStore } from 'zustand'
1
+ import { createContext, RefObject, useContext } from 'react'
3
2
 
4
- import { clamp } from '@tremolo-ui/functions'
3
+ import type { InputEventOption, Scale, ValueRange } from '@tremolo-ui/functions'
5
4
 
6
- import { parseValue, Units } from './type'
7
-
8
- type State = {
9
- value: string
10
- valueAsNumber: number
11
- step: number
5
+ export type NumberInputContextValue = {
6
+ value: number
12
7
 
8
+ /**
9
+ * The range the caller asked for. Left undefined when unbounded, which is
10
+ * what `aria-valuemin` / `aria-valuemax` and the steppers need to tell apart
11
+ * from a range that happens to sit at the edge of the safe integers.
12
+ */
13
13
  min?: number
14
14
  max?: number
15
- units?: string | Units
16
- digit?: number
17
- readonly?: boolean
18
- keepWithinRange?: boolean
19
- onChange?: (value: number, text: string) => void
20
- }
21
-
22
- type Action = {
23
- increment: () => void
24
- decrement: () => void
25
- change: (value: string) => void
15
+ step: number
16
+ /** How the value is distributed across the travel. */
17
+ scale: Scale
18
+
19
+ disabled: boolean
20
+ readonly: boolean
21
+ clampValue: boolean
22
+
23
+ /**
24
+ * The effective scaling for `applyDelta` and for clamping on commit, with the
25
+ * unbounded ends filled in. Ignores `min` / `max` when `clampValue` is off.
26
+ */
27
+ range: ValueRange
28
+
29
+ keyboard: InputEventOption | null
30
+ /** Pixels of vertical drag on `Stepper` that move the value by one `step`. */
31
+ drag: number | null
32
+
33
+ /** What the input shows: the draft while editing, the formatted value otherwise. */
34
+ text: string
35
+ editing: boolean
36
+ /** Whether the value sits outside `min` / `max`. Not judged while editing. */
37
+ outOfRange: boolean
38
+ /** Whether a stepper would have no effect, for `aria-disabled`. */
39
+ atMin: boolean
40
+ atMax: boolean
41
+
42
+ format: (value: number) => string
43
+ parse: (text: string) => number
44
+
45
+ /** Typing. Replaces the draft and reports the parsed value, unclamped. */
46
+ setDraft: (text: string) => void
47
+ /** Blur or Enter. Clamps the draft, reports it, and drops the draft. */
48
+ commitDraft: () => void
49
+ /** Drops the draft and replaces the value: steppers, wheel, keyboard, drag. */
50
+ changeValue: (next: number) => void
51
+ /** Moves the value by one `option` in `direction`, normally +1 or -1. */
52
+ nudge: (direction: number, option: InputEventOption) => void
53
+
54
+ /** `InputField` registers itself here; `Root` focuses it through its methods. */
55
+ inputRef: RefObject<HTMLInputElement | null>
26
56
  }
27
57
 
28
- type NumberInputStore = ReturnType<typeof createNumberInputStore>
58
+ const NumberInputContext = createContext<NumberInputContextValue | null>(null)
29
59
 
30
- type ProviderProps = Partial<Omit<State, 'value'> & { value: number | string }>
60
+ export const NumberInputProvider = NumberInputContext.Provider
31
61
 
32
- export function safeClamp(
33
- value: number,
34
- min = Number.MIN_SAFE_INTEGER,
35
- max = Number.MAX_SAFE_INTEGER,
62
+ /**
63
+ * The only state behind this is the editing draft; `value` comes from the props
64
+ * of `Root` and everything else is derived during render.
65
+ */
66
+ export function useNumberInputContext(): NumberInputContextValue
67
+ export function useNumberInputContext<T>(
68
+ selector: (state: NumberInputContextValue) => T,
69
+ ): T
70
+ export function useNumberInputContext<T>(
71
+ selector?: (state: NumberInputContextValue) => T,
36
72
  ) {
37
- return clamp(value, min, max)
73
+ const context = useContext(NumberInputContext)
74
+ if (!context)
75
+ throw new Error('Missing NumberInputContext.Provider in the tree')
76
+ return selector ? selector(context) : context
38
77
  }
39
78
 
40
- const createNumberInputStore = (initProps?: ProviderProps) => {
41
- const DEFAULT_PROPS: State = {
42
- value: '',
43
- valueAsNumber: 0,
44
- min: 0,
45
- max: 0,
46
- step: 1,
47
- readonly: false,
48
- keepWithinRange: true,
49
- onChange: (_v: number, _t: string) => {},
50
- }
51
-
52
- return createStore<State & Action>()((set) => ({
53
- ...DEFAULT_PROPS,
54
- ...initProps,
55
- ...{
56
- value: parseValue(
57
- String(initProps?.value || ''),
58
- initProps?.units,
59
- initProps?.digit,
60
- ).formatValue,
61
- valueAsNumber: parseValue(
62
- String(initProps?.value || ''),
63
- initProps?.units,
64
- initProps?.digit,
65
- ).rawValue,
66
- },
67
- increment: () =>
68
- set((state) => {
69
- if (state.readonly) return {}
70
- const current = parseValue(
71
- state.value,
72
- state.units,
73
- state.digit,
74
- ).rawValue
75
- let next = current + (state.step ?? 1)
76
- if (state.keepWithinRange) {
77
- next = safeClamp(next, state.min, state.max)
78
- }
79
- const v = parseValue(String(next), state.units, state.digit).formatValue
80
- state?.onChange?.(next, v)
81
- return {
82
- value: v,
83
- valueAsNumber: next,
84
- }
85
- }),
86
- decrement: () =>
87
- set((state) => {
88
- if (state.readonly) return {}
89
- const current = parseValue(
90
- state.value,
91
- state.units,
92
- state.digit,
93
- ).rawValue
94
- let next = current - (state.step ?? 1)
95
- if (state.keepWithinRange) {
96
- next = safeClamp(next, state.min, state.max)
97
- }
98
- const v = parseValue(String(next), state.units, state.digit).formatValue
99
- state?.onChange?.(next, v)
100
- return {
101
- value: v,
102
- valueAsNumber: next,
103
- }
104
- }),
105
- change: (value) =>
106
- set((state) => {
107
- if (state.readonly) return {}
108
- const v = value ?? ''
109
- return {
110
- value: v,
111
- valueAsNumber: parseValue(v, state.units, state.digit).rawValue,
112
- }
113
- }),
114
- }))
79
+ /** Set by `Stepper` once a drag has actually moved, so the steppers stand down. */
80
+ export type StepperContextValue = {
81
+ draggingRef: RefObject<boolean>
115
82
  }
116
83
 
117
- const NumberInputContext = createContext<NumberInputStore | null>(null)
118
-
119
- type NumberInputProviderProps = React.PropsWithChildren<ProviderProps>
120
-
121
- export function NumberInputProvider({
122
- children,
123
- ...props
124
- }: NumberInputProviderProps) {
125
- const storeRef = useRef<NumberInputStore>(null)
126
- if (!storeRef.current) {
127
- storeRef.current = createNumberInputStore(props)
128
- }
129
-
130
- useEffect(() => {
131
- if (storeRef.current) {
132
- const parsed = parseValue(
133
- String(props?.value || ''),
134
- props?.units,
135
- props?.digit,
136
- )
137
- storeRef.current.setState({
138
- ...props,
139
- ...{
140
- value: parsed.formatValue,
141
- valueAsNumber: parsed.rawValue,
142
- },
143
- })
144
- } else {
145
- storeRef.current = createNumberInputStore(props)
146
- }
147
- }, [props])
148
-
149
- return (
150
- <NumberInputContext.Provider value={storeRef.current}>
151
- {children}
152
- </NumberInputContext.Provider>
153
- )
154
- }
84
+ const StepperContext = createContext<StepperContextValue | null>(null)
155
85
 
156
- export function useNumberInputContext<T>(
157
- selector: (state: State & Action) => T,
158
- ): T {
159
- const store = useContext(NumberInputContext)
160
- if (!store) throw new Error('Missing NumberInputContext.Provider in the tree')
161
- return useStore(store, selector)
86
+ export const StepperProvider = StepperContext.Provider
87
+
88
+ export function useStepperContext(): StepperContextValue | null {
89
+ return useContext(StepperContext)
162
90
  }
@@ -1,6 +1,7 @@
1
- .tremolo-number-input-wrapper {
1
+ .tremolo-number-input {
2
2
  --border-radius: 4px;
3
3
  --stepper-width: 20px;
4
+ --stepper-icon-size: 12px;
4
5
  --active-color: oklch(59.5% 0.17548 266.373);
5
6
  --border-color: oklch(81% 0 0);
6
7
 
@@ -9,70 +10,34 @@
9
10
  position: relative;
10
11
  width: 140px;
11
12
  height: min-content;
12
- border-style: solid;
13
- border-color: var(--border-color);
14
- border-width: 0px;
13
+ border: 1px solid var(--border-color);
14
+ border-radius: var(--border-radius);
15
15
  outline: none;
16
16
  transition: all 0.1s;
17
17
 
18
- &:not(:focus-within):has(> .tremolo-number-input[data-error='true']) {
19
- border-color: oklch(64.245% 0.21266 25.499);
20
- }
21
-
22
- &[data-variant='outline'] {
23
- border-width: 1px;
24
- border-radius: var(--border-radius);
25
-
26
- &:hover {
27
- --border-color: oklch(65.335% 0 0);
28
- }
29
-
30
- &:focus-within {
31
- --border-color: var(--active-color);
32
- box-shadow: 0px 0px 0px 2px
33
- color-mix(in srgb, var(--active-color) 10%, transparent);
34
- }
18
+ &:hover {
19
+ --border-color: oklch(65.335% 0 0);
35
20
  }
36
21
 
37
- &[data-variant='filled'] {
38
- --bg: oklch(95.88% 0 0);
39
- border-width: 1px;
40
- border-radius: var(--border-radius);
41
- border-color: transparent;
42
- background-color: var(--bg);
43
-
44
- &:hover {
45
- --bg: oklch(93.166% 0 0);
46
- }
47
-
48
- &:focus-within {
49
- background-color: inherit;
50
- --border-color: var(--active-color);
51
- box-shadow: 0px 0px 0px 2px
52
- color-mix(in srgb, var(--active-color) 10%, transparent);
53
- }
22
+ &:focus-within {
23
+ --border-color: var(--active-color);
24
+ box-shadow: 0px 0px 0px 2px
25
+ color-mix(in srgb, var(--active-color) 10%, transparent);
54
26
  }
55
27
 
56
- &[data-variant='flushed'] {
57
- border-bottom-width: 1px;
58
-
59
- &:hover {
60
- --border-color: oklch(65.335% 0 0);
61
- }
62
-
63
- &:focus-within {
64
- --border-color: var(--active-color);
65
- box-shadow: 0px 2px 0px 0px
66
- color-mix(in srgb, var(--active-color) 10%, transparent);
67
- }
28
+ &:not(:focus-within):has(
29
+ .tremolo-number-input-field[data-out-of-range='true']
30
+ ) {
31
+ border-color: oklch(64.245% 0.21266 25.499);
68
32
  }
69
33
 
70
- &[data-stepper='false'] {
34
+ /* Only leave room for the stepper when one was composed in. */
35
+ &:not(:has(.tremolo-number-input-stepper)) {
71
36
  --stepper-width: 0px;
72
37
  }
73
38
  }
74
39
 
75
- .tremolo-number-input {
40
+ .tremolo-number-input-field {
76
41
  display: block;
77
42
  color: inherit;
78
43
  width: calc(100% - 20px - var(--stepper-width));
@@ -100,26 +65,28 @@
100
65
  width: var(--stepper-width);
101
66
  transition: all 0.1s linear;
102
67
 
103
- &[data-dynamic='true'] {
104
- opacity: 0;
105
- transform: translateX(40%);
106
- }
68
+ /* Kept out of the way until the input is being used. */
69
+ opacity: 0;
70
+ transform: translateX(40%);
107
71
  }
108
72
 
109
- :is(
110
- .tremolo-number-input-wrapper:hover,
111
- .tremolo-number-input-wrapper:focus-within
112
- )
73
+ :is(.tremolo-number-input:hover, .tremolo-number-input:focus-within)
113
74
  .tremolo-number-input-stepper {
114
75
  opacity: 1;
115
76
  transform: translateX(0);
116
77
  }
117
78
 
118
- .tremolo-number-input-increment-stepper {
119
- border-inline-start-width: 1px;
120
- border-inline-start-style: solid;
121
- border-inline-start-color: oklch(84.522% 0 0);
122
- font-size: calc(0.75rem);
79
+ .tremolo-number-input-stepper-icon {
80
+ width: var(--stepper-icon-size);
81
+ height: var(--stepper-icon-size);
82
+ }
83
+
84
+ :is(
85
+ .tremolo-number-input-increment-stepper,
86
+ .tremolo-number-input-decrement-stepper
87
+ ) {
88
+ border-inline-start: 1px solid oklch(84.522% 0 0);
89
+ font-size: 0.75rem;
123
90
  display: flex;
124
91
  justify-content: center;
125
92
  align-items: center;
@@ -127,59 +94,28 @@
127
94
  max-height: 50%;
128
95
  user-select: none;
129
96
  cursor: pointer;
130
- border-top-right-radius: var(--border-radius);
131
97
 
132
98
  &[aria-disabled='false']:active {
133
99
  background-color: oklch(37.798% 0.03812 254.612 / 0.055);
134
100
  }
135
101
 
136
- &[aria-disabled='true'] {
137
- cursor: not-allowed;
138
- }
139
-
102
+ &[aria-disabled='true'],
140
103
  &[aria-readonly='true'] {
141
104
  cursor: not-allowed;
142
105
  }
143
106
  }
144
107
 
108
+ .tremolo-number-input-increment-stepper {
109
+ border-top-right-radius: var(--border-radius);
110
+ }
111
+
145
112
  .tremolo-number-input-decrement-stepper {
146
- border-inline-start-width: 1px;
147
- border-inline-start-style: solid;
148
- border-inline-start-color: oklch(84.522% 0 0);
149
113
  border-top: 1px solid oklch(84.522% 0 0);
150
- font-size: calc(0.75rem);
151
- display: flex;
152
- justify-content: center;
153
- align-items: center;
154
- flex: 1 1 0%;
155
- max-height: 50%;
156
- user-select: none;
157
- cursor: pointer;
158
114
  border-bottom-right-radius: var(--border-radius);
159
-
160
- &[aria-disabled='false']:active {
161
- background-color: oklch(37.798% 0.03812 254.612 / 0.055);
162
- }
163
-
164
- &[aria-disabled='true'] {
165
- cursor: not-allowed;
166
- }
167
-
168
- &[aria-readonly='true'] {
169
- cursor: not-allowed;
170
- }
171
115
  }
172
116
 
173
117
  :where(.dark, [data-theme='dark']) {
174
- .tremolo-number-input-wrapper {
118
+ .tremolo-number-input {
175
119
  --border-color: oklch(52% 0 0);
176
-
177
- &[data-variant='filled'] {
178
- --bg: oklch(43.5% 0 0);
179
-
180
- &:hover {
181
- --bg: oklch(46.6% 0 0);
182
- }
183
- }
184
120
  }
185
121
  }