@tremolo-ui/react 0.0.8 → 0.0.10

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 (148) hide show
  1. package/dist/cjs/index.js +439 -275
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/cjs/types/components/AnimationCanvas/index.d.ts +9 -4
  4. package/dist/cjs/types/components/AnimationCanvas/index.d.ts.map +1 -1
  5. package/dist/cjs/types/components/AnimationKnob/index.d.ts +2 -0
  6. package/dist/cjs/types/components/AnimationKnob/index.d.ts.map +1 -1
  7. package/dist/cjs/types/components/DragObserver/index.d.ts +3 -1
  8. package/dist/cjs/types/components/DragObserver/index.d.ts.map +1 -1
  9. package/dist/cjs/types/components/Knob/index.d.ts +2 -0
  10. package/dist/cjs/types/components/Knob/index.d.ts.map +1 -1
  11. package/dist/cjs/types/components/NumberInput/DecrementStepper.d.ts +7 -0
  12. package/dist/cjs/types/components/NumberInput/DecrementStepper.d.ts.map +1 -0
  13. package/dist/cjs/types/components/NumberInput/IncrementStepper.d.ts +7 -0
  14. package/dist/cjs/types/components/NumberInput/IncrementStepper.d.ts.map +1 -0
  15. package/dist/cjs/types/components/NumberInput/Stepper.d.ts +8 -0
  16. package/dist/cjs/types/components/NumberInput/Stepper.d.ts.map +1 -0
  17. package/dist/cjs/types/components/NumberInput/context.d.ts +27 -0
  18. package/dist/cjs/types/components/NumberInput/context.d.ts.map +1 -0
  19. package/dist/cjs/types/components/NumberInput/index.d.ts +31 -16
  20. package/dist/cjs/types/components/NumberInput/index.d.ts.map +1 -1
  21. package/dist/cjs/types/components/Piano/BlackKey.d.ts +1 -0
  22. package/dist/cjs/types/components/Piano/BlackKey.d.ts.map +1 -1
  23. package/dist/cjs/types/components/Piano/KeyLabel.d.ts +2 -0
  24. package/dist/cjs/types/components/Piano/KeyLabel.d.ts.map +1 -1
  25. package/dist/cjs/types/components/Piano/WhiteKey.d.ts +3 -0
  26. package/dist/cjs/types/components/Piano/WhiteKey.d.ts.map +1 -1
  27. package/dist/cjs/types/components/Piano/index.d.ts +6 -4
  28. package/dist/cjs/types/components/Piano/index.d.ts.map +1 -1
  29. package/dist/cjs/types/components/Piano/key.d.ts +8 -2
  30. package/dist/cjs/types/components/Piano/key.d.ts.map +1 -1
  31. package/dist/cjs/types/components/Piano/keyboardShortcuts.d.ts +2 -0
  32. package/dist/cjs/types/components/Piano/keyboardShortcuts.d.ts.map +1 -1
  33. package/dist/cjs/types/components/Slider/Scale.d.ts +2 -0
  34. package/dist/cjs/types/components/Slider/Scale.d.ts.map +1 -1
  35. package/dist/cjs/types/components/Slider/ScaleOption.d.ts +2 -0
  36. package/dist/cjs/types/components/Slider/ScaleOption.d.ts.map +1 -1
  37. package/dist/cjs/types/components/Slider/Thumb.d.ts +4 -0
  38. package/dist/cjs/types/components/Slider/Thumb.d.ts.map +1 -1
  39. package/dist/cjs/types/components/Slider/Track.d.ts +6 -1
  40. package/dist/cjs/types/components/Slider/Track.d.ts.map +1 -1
  41. package/dist/cjs/types/components/Slider/context.d.ts +15 -12
  42. package/dist/cjs/types/components/Slider/context.d.ts.map +1 -1
  43. package/dist/cjs/types/components/Slider/index.d.ts +8 -5
  44. package/dist/cjs/types/components/Slider/index.d.ts.map +1 -1
  45. package/dist/cjs/types/components/WheelObserver/index.d.ts +1 -0
  46. package/dist/cjs/types/components/WheelObserver/index.d.ts.map +1 -1
  47. package/dist/cjs/types/components/XYPad/Area.d.ts +2 -0
  48. package/dist/cjs/types/components/XYPad/Area.d.ts.map +1 -1
  49. package/dist/cjs/types/components/XYPad/Thumb.d.ts +3 -0
  50. package/dist/cjs/types/components/XYPad/Thumb.d.ts.map +1 -1
  51. package/dist/cjs/types/components/XYPad/index.d.ts +4 -0
  52. package/dist/cjs/types/components/XYPad/index.d.ts.map +1 -1
  53. package/dist/cjs/types/hooks/useAnimationFrame.d.ts +3 -0
  54. package/dist/cjs/types/hooks/useAnimationFrame.d.ts.map +1 -0
  55. package/dist/cjs/types/hooks/useDrag.d.ts +15 -0
  56. package/dist/cjs/types/hooks/useDrag.d.ts.map +1 -0
  57. package/dist/cjs/types/hooks/useRefCallbackEvent.d.ts +1 -4
  58. package/dist/cjs/types/hooks/useRefCallbackEvent.d.ts.map +1 -1
  59. package/dist/esm/index.js +437 -266
  60. package/dist/esm/index.js.map +1 -1
  61. package/dist/esm/types/components/AnimationCanvas/index.d.ts +9 -4
  62. package/dist/esm/types/components/AnimationCanvas/index.d.ts.map +1 -1
  63. package/dist/esm/types/components/AnimationKnob/index.d.ts +2 -0
  64. package/dist/esm/types/components/AnimationKnob/index.d.ts.map +1 -1
  65. package/dist/esm/types/components/DragObserver/index.d.ts +3 -1
  66. package/dist/esm/types/components/DragObserver/index.d.ts.map +1 -1
  67. package/dist/esm/types/components/Knob/index.d.ts +2 -0
  68. package/dist/esm/types/components/Knob/index.d.ts.map +1 -1
  69. package/dist/esm/types/components/NumberInput/DecrementStepper.d.ts +7 -0
  70. package/dist/esm/types/components/NumberInput/DecrementStepper.d.ts.map +1 -0
  71. package/dist/esm/types/components/NumberInput/IncrementStepper.d.ts +7 -0
  72. package/dist/esm/types/components/NumberInput/IncrementStepper.d.ts.map +1 -0
  73. package/dist/esm/types/components/NumberInput/Stepper.d.ts +8 -0
  74. package/dist/esm/types/components/NumberInput/Stepper.d.ts.map +1 -0
  75. package/dist/esm/types/components/NumberInput/context.d.ts +27 -0
  76. package/dist/esm/types/components/NumberInput/context.d.ts.map +1 -0
  77. package/dist/esm/types/components/NumberInput/index.d.ts +31 -16
  78. package/dist/esm/types/components/NumberInput/index.d.ts.map +1 -1
  79. package/dist/esm/types/components/Piano/BlackKey.d.ts +1 -0
  80. package/dist/esm/types/components/Piano/BlackKey.d.ts.map +1 -1
  81. package/dist/esm/types/components/Piano/KeyLabel.d.ts +2 -0
  82. package/dist/esm/types/components/Piano/KeyLabel.d.ts.map +1 -1
  83. package/dist/esm/types/components/Piano/WhiteKey.d.ts +3 -0
  84. package/dist/esm/types/components/Piano/WhiteKey.d.ts.map +1 -1
  85. package/dist/esm/types/components/Piano/index.d.ts +6 -4
  86. package/dist/esm/types/components/Piano/index.d.ts.map +1 -1
  87. package/dist/esm/types/components/Piano/key.d.ts +8 -2
  88. package/dist/esm/types/components/Piano/key.d.ts.map +1 -1
  89. package/dist/esm/types/components/Piano/keyboardShortcuts.d.ts +2 -0
  90. package/dist/esm/types/components/Piano/keyboardShortcuts.d.ts.map +1 -1
  91. package/dist/esm/types/components/Slider/Scale.d.ts +2 -0
  92. package/dist/esm/types/components/Slider/Scale.d.ts.map +1 -1
  93. package/dist/esm/types/components/Slider/ScaleOption.d.ts +2 -0
  94. package/dist/esm/types/components/Slider/ScaleOption.d.ts.map +1 -1
  95. package/dist/esm/types/components/Slider/Thumb.d.ts +4 -0
  96. package/dist/esm/types/components/Slider/Thumb.d.ts.map +1 -1
  97. package/dist/esm/types/components/Slider/Track.d.ts +6 -1
  98. package/dist/esm/types/components/Slider/Track.d.ts.map +1 -1
  99. package/dist/esm/types/components/Slider/context.d.ts +15 -12
  100. package/dist/esm/types/components/Slider/context.d.ts.map +1 -1
  101. package/dist/esm/types/components/Slider/index.d.ts +8 -5
  102. package/dist/esm/types/components/Slider/index.d.ts.map +1 -1
  103. package/dist/esm/types/components/WheelObserver/index.d.ts +1 -0
  104. package/dist/esm/types/components/WheelObserver/index.d.ts.map +1 -1
  105. package/dist/esm/types/components/XYPad/Area.d.ts +2 -0
  106. package/dist/esm/types/components/XYPad/Area.d.ts.map +1 -1
  107. package/dist/esm/types/components/XYPad/Thumb.d.ts +3 -0
  108. package/dist/esm/types/components/XYPad/Thumb.d.ts.map +1 -1
  109. package/dist/esm/types/components/XYPad/index.d.ts +4 -0
  110. package/dist/esm/types/components/XYPad/index.d.ts.map +1 -1
  111. package/dist/esm/types/hooks/useAnimationFrame.d.ts +3 -0
  112. package/dist/esm/types/hooks/useAnimationFrame.d.ts.map +1 -0
  113. package/dist/esm/types/hooks/useDrag.d.ts +15 -0
  114. package/dist/esm/types/hooks/useDrag.d.ts.map +1 -0
  115. package/dist/esm/types/hooks/useRefCallbackEvent.d.ts +1 -4
  116. package/dist/esm/types/hooks/useRefCallbackEvent.d.ts.map +1 -1
  117. package/dist/styles/index.css +110 -13
  118. package/package.json +8 -5
  119. package/src/components/AnimationCanvas/index.tsx +13 -4
  120. package/src/components/AnimationKnob/index.tsx +24 -42
  121. package/src/components/DragObserver/index.tsx +14 -52
  122. package/src/components/Knob/index.tsx +23 -40
  123. package/src/components/NumberInput/DecrementStepper.tsx +52 -0
  124. package/src/components/NumberInput/IncrementStepper.tsx +52 -0
  125. package/src/components/NumberInput/Stepper.tsx +25 -0
  126. package/src/components/NumberInput/context.tsx +150 -0
  127. package/src/components/NumberInput/index.css +102 -13
  128. package/src/components/NumberInput/index.tsx +164 -55
  129. package/src/components/Piano/BlackKey.tsx +15 -11
  130. package/src/components/Piano/KeyLabel.tsx +2 -0
  131. package/src/components/Piano/WhiteKey.tsx +17 -11
  132. package/src/components/Piano/index.tsx +16 -14
  133. package/src/components/Piano/key.ts +8 -2
  134. package/src/components/Piano/keyboardShortcuts.ts +2 -0
  135. package/src/components/Slider/Scale.tsx +8 -6
  136. package/src/components/Slider/ScaleOption.tsx +8 -6
  137. package/src/components/Slider/Thumb.tsx +7 -3
  138. package/src/components/Slider/Track.tsx +27 -15
  139. package/src/components/Slider/context.tsx +65 -51
  140. package/src/components/Slider/index.css +8 -0
  141. package/src/components/Slider/index.tsx +19 -10
  142. package/src/components/WheelObserver/index.tsx +1 -0
  143. package/src/components/XYPad/Area.tsx +2 -0
  144. package/src/components/XYPad/Thumb.tsx +3 -0
  145. package/src/components/XYPad/index.tsx +6 -2
  146. package/src/hooks/useAnimationFrame.ts +18 -0
  147. package/src/hooks/useDrag.ts +92 -0
  148. package/src/hooks/useRefCallbackEvent.ts +4 -7
@@ -0,0 +1,52 @@
1
+ import clsx from 'clsx'
2
+ import { ComponentPropsWithoutRef, ReactNode } from 'react'
3
+
4
+ import { useNumberInputContext } from './context'
5
+
6
+ export interface DecrementStepperProps {
7
+ size?: number
8
+ children?: ReactNode
9
+ }
10
+
11
+ export function DecrementStepper({
12
+ size = 12,
13
+ children,
14
+ className,
15
+ ...props
16
+ }: DecrementStepperProps &
17
+ Omit<ComponentPropsWithoutRef<'div'>, keyof DecrementStepperProps>) {
18
+ const min = useNumberInputContext((s) => s.min)
19
+ const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber)
20
+ const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange)
21
+ const decrement = useNumberInputContext((s) => s.decrement)
22
+
23
+ // TODO: add long press event
24
+
25
+ return (
26
+ <div
27
+ className={clsx('tremolo-number-input-decrement-stepper', className)}
28
+ role="button"
29
+ tabIndex={-1}
30
+ aria-disabled={
31
+ keepWithinRange && valueAsNumber <= (min ?? Number.MIN_SAFE_INTEGER)
32
+ }
33
+ onPointerDown={decrement}
34
+ {...props}
35
+ >
36
+ {children || (
37
+ <svg
38
+ width={size}
39
+ height={size}
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth="2"
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ >
47
+ <polyline points="6 9 12 15 18 9" />
48
+ </svg>
49
+ )}
50
+ </div>
51
+ )
52
+ }
@@ -0,0 +1,52 @@
1
+ import clsx from 'clsx'
2
+ import { ComponentPropsWithoutRef, ReactNode } from 'react'
3
+
4
+ import { useNumberInputContext } from './context'
5
+
6
+ export interface IncrementStepperProps {
7
+ size?: number
8
+ children?: ReactNode
9
+ }
10
+
11
+ export function IncrementStepper({
12
+ size = 12,
13
+ children,
14
+ className,
15
+ ...props
16
+ }: IncrementStepperProps &
17
+ Omit<ComponentPropsWithoutRef<'div'>, keyof IncrementStepperProps>) {
18
+ const max = useNumberInputContext((s) => s.max)
19
+ const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber)
20
+ const keepWithinRange = useNumberInputContext((s) => s.keepWithinRange)
21
+ const increment = useNumberInputContext((s) => s.increment)
22
+
23
+ // TODO: add long press event
24
+
25
+ return (
26
+ <div
27
+ className={clsx('tremolo-number-input-increment-stepper', className)}
28
+ role="button"
29
+ tabIndex={-1}
30
+ aria-disabled={
31
+ keepWithinRange && valueAsNumber >= (max ?? Number.MAX_SAFE_INTEGER)
32
+ }
33
+ onPointerDown={increment}
34
+ {...props}
35
+ >
36
+ {children || (
37
+ <svg
38
+ width={size}
39
+ height={size}
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth="2"
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ >
47
+ <polyline points="18 15 12 9 6 15" />
48
+ </svg>
49
+ )}
50
+ </div>
51
+ )
52
+ }
@@ -0,0 +1,25 @@
1
+ import clsx from 'clsx'
2
+ import { ComponentPropsWithoutRef, ReactNode } from 'react'
3
+
4
+ export interface StepperProps {
5
+ /** Display only when hovering. */
6
+ dynamic?: boolean
7
+ children?: ReactNode
8
+ }
9
+
10
+ export function Stepper({
11
+ dynamic = true,
12
+ children,
13
+ className,
14
+ ...props
15
+ }: StepperProps & Omit<ComponentPropsWithoutRef<'div'>, keyof StepperProps>) {
16
+ return (
17
+ <div
18
+ className={clsx('tremolo-number-input-stepper', className)}
19
+ data-dynamic={dynamic}
20
+ {...props}
21
+ >
22
+ {children}
23
+ </div>
24
+ )
25
+ }
@@ -0,0 +1,150 @@
1
+ import { createContext, useContext, useEffect, useRef } from 'react'
2
+ import { createStore, useStore } from 'zustand'
3
+
4
+ import { clamp } from '@tremolo-ui/functions'
5
+ import { parseValue, Units } from '@tremolo-ui/functions/NumberInput'
6
+
7
+ type State = {
8
+ value: string
9
+ valueAsNumber: number
10
+ min?: number
11
+ max?: number
12
+ step?: number
13
+ keepWithinRange?: boolean
14
+ units?: string | Units
15
+ digit?: number
16
+ onChange?: (value: number, text: string) => void
17
+ }
18
+
19
+ type Action = {
20
+ increment: () => void
21
+ decrement: () => void
22
+ change: (value: string) => void
23
+ }
24
+
25
+ type NumberInputStore = ReturnType<typeof createNumberInputStore>
26
+
27
+ type ProviderProps = Partial<Omit<State, 'value'> & { value: number | string }>
28
+
29
+ export function safeClamp(
30
+ value: number,
31
+ min = Number.MIN_SAFE_INTEGER,
32
+ max = Number.MAX_SAFE_INTEGER,
33
+ ) {
34
+ return clamp(value, min, max)
35
+ }
36
+
37
+ const createNumberInputStore = (initProps?: ProviderProps) => {
38
+ const DEFAULT_PROPS: State = {
39
+ value: '',
40
+ valueAsNumber: 0,
41
+ min: 0,
42
+ max: 0,
43
+ step: 1,
44
+ keepWithinRange: true,
45
+ onChange: (_v: number, _t: string) => {},
46
+ }
47
+
48
+ return createStore<State & Action>()((set) => ({
49
+ ...DEFAULT_PROPS,
50
+ ...initProps,
51
+ ...{
52
+ value: parseValue(
53
+ String(initProps?.value || ''),
54
+ initProps?.units,
55
+ initProps?.digit,
56
+ ).formatValue,
57
+ valueAsNumber: parseValue(
58
+ String(initProps?.value || ''),
59
+ initProps?.units,
60
+ initProps?.digit,
61
+ ).rawValue,
62
+ },
63
+ increment: () =>
64
+ set((state) => {
65
+ let next =
66
+ parseValue(state.value, state.units, state.digit).rawValue +
67
+ (state.step ?? 1)
68
+ if (state.keepWithinRange) {
69
+ next = safeClamp(next, state.min, state.max)
70
+ }
71
+ const v = parseValue(String(next), state.units, state.digit).formatValue
72
+ state?.onChange?.(next, v)
73
+ return {
74
+ value: v,
75
+ valueAsNumber: next,
76
+ }
77
+ }),
78
+ decrement: () =>
79
+ set((state) => {
80
+ let next =
81
+ parseValue(state.value, state.units, state.digit).rawValue -
82
+ (state.step ?? 1)
83
+ if (state.keepWithinRange) {
84
+ next = safeClamp(next, state.min, state.max)
85
+ }
86
+ const v = parseValue(String(next), state.units, state.digit).formatValue
87
+ state?.onChange?.(next, v)
88
+ return {
89
+ value: v,
90
+ valueAsNumber: next,
91
+ }
92
+ }),
93
+ change: (value) =>
94
+ set((state) => {
95
+ const v = value ?? ''
96
+ return {
97
+ value: v,
98
+ valueAsNumber: parseValue(v, state.units, state.digit).rawValue,
99
+ }
100
+ }),
101
+ }))
102
+ }
103
+
104
+ const NumberInputContext = createContext<NumberInputStore | null>(null)
105
+
106
+ type NumberInputProviderProps = React.PropsWithChildren<ProviderProps>
107
+
108
+ export function NumberInputProvider({
109
+ children,
110
+ ...props
111
+ }: NumberInputProviderProps) {
112
+ const storeRef = useRef<NumberInputStore>()
113
+ if (!storeRef.current) {
114
+ storeRef.current = createNumberInputStore(props)
115
+ }
116
+
117
+ useEffect(() => {
118
+ if (storeRef.current) {
119
+ const parsed = parseValue(
120
+ String(props?.value || ''),
121
+ props?.units,
122
+ props?.digit,
123
+ )
124
+ storeRef.current.setState({
125
+ ...props,
126
+ ...{
127
+ value: parsed.formatValue,
128
+ valueAsNumber: parsed.rawValue,
129
+ },
130
+ })
131
+ } else {
132
+ storeRef.current = createNumberInputStore(props)
133
+ }
134
+ }, [props])
135
+
136
+ return (
137
+ <NumberInputContext.Provider value={storeRef.current}>
138
+ {children}
139
+ </NumberInputContext.Provider>
140
+ )
141
+ }
142
+
143
+ /** @category NumberInput */
144
+ export function useNumberInputContext<T>(
145
+ selector: (state: State & Action) => T,
146
+ ): T {
147
+ const store = useContext(NumberInputContext)
148
+ if (!store) throw new Error('Missing NumberInputContext.Provider in the tree')
149
+ return useStore(store, selector)
150
+ }
@@ -1,25 +1,114 @@
1
- .tremolo-number-input {
2
- display: inline-block;
3
- font: inherit;
4
- margin: 0px;
5
- padding-top: 6px;
6
- padding-bottom: 6px;
7
- padding-left: 10px;
8
- padding-right: 10px;
1
+ .tremolo-number-input-wrapper {
2
+ --tremolo-number-input-border-radius: 4px;
3
+ --tremolo-number-input-stepper-width: 20px;
4
+
5
+ display: inline-flex;
6
+ flex-direction: row;
7
+ position: relative;
8
+
9
9
  border-style: solid;
10
10
  border-width: 1px;
11
11
  border-color: #bbb;
12
- border-radius: 4px;
13
- outline: 0px;
14
- transition: all 0.1s linear;
15
- appearance: textfield;
12
+ border-radius: var(--tremolo-number-input-border-radius);
13
+
14
+ width: 140px;
15
+ height: min-content;
16
16
 
17
17
  &:hover {
18
18
  border-color: var(--tremolo-theme-color);
19
19
  }
20
20
 
21
- &:focus {
21
+ &:not(:focus-within):has(> .tremolo-number-input[data-error='true']) {
22
+ border-color: #f34343;
23
+ }
24
+
25
+ &:focus-within {
22
26
  border-color: var(--tremolo-theme-color);
23
27
  box-shadow: 0px 0px 0px 2px rgba(var(--tremolo-theme-color-rgb), 0.1);
24
28
  }
29
+
30
+ &[data-stepper='false'] {
31
+ --tremolo-number-input-stepper-width: 0px;
32
+ }
33
+ }
34
+
35
+ .tremolo-number-input {
36
+ display: block;
37
+ width: calc(100% - 20px - var(--tremolo-number-input-stepper-width));
38
+ background-color: inherit;
39
+ font: inherit;
40
+ padding: 6px 10px;
41
+
42
+ margin: 0;
43
+ border: none;
44
+ outline: none;
45
+ appearance: textfield;
46
+ }
47
+
48
+ .tremolo-number-input-stepper {
49
+ display: flex;
50
+ flex-direction: column;
51
+ height: 100%;
52
+ position: absolute;
53
+ top: 0;
54
+ right: 0;
55
+ width: var(--tremolo-number-input-stepper-width);
56
+ transition: all 0.1s linear;
57
+
58
+ &[data-dynamic='true'] {
59
+ opacity: 0;
60
+ transform: translateX(40%);
61
+ }
62
+ }
63
+
64
+ .tremolo-number-input-wrapper:hover .tremolo-number-input-stepper {
65
+ opacity: 1;
66
+ transform: translateX(0);
67
+ }
68
+
69
+ .tremolo-number-input-increment-stepper {
70
+ border-inline-start-width: 1px;
71
+ border-inline-start-style: solid;
72
+ border-inline-start-color: #ccc;
73
+ font-size: calc(0.75rem);
74
+ display: flex;
75
+ justify-content: center;
76
+ align-items: center;
77
+ flex: 1 1 0%;
78
+ max-height: 50%;
79
+ user-select: none;
80
+ cursor: pointer;
81
+ border-top-right-radius: var(--tremolo-number-input-border-radius);
82
+
83
+ &[aria-disabled='false']:active {
84
+ background-color: #3443560e;
85
+ }
86
+
87
+ &[aria-disabled='true'] {
88
+ cursor: not-allowed;
89
+ }
90
+ }
91
+
92
+ .tremolo-number-input-decrement-stepper {
93
+ border-inline-start-width: 1px;
94
+ border-inline-start-style: solid;
95
+ border-inline-start-color: #ccc;
96
+ border-top: 1px solid #ccc;
97
+ font-size: calc(0.75rem);
98
+ display: flex;
99
+ justify-content: center;
100
+ align-items: center;
101
+ flex: 1 1 0%;
102
+ max-height: 50%;
103
+ user-select: none;
104
+ cursor: pointer;
105
+ border-bottom-right-radius: var(--tremolo-number-input-border-radius);
106
+
107
+ &[aria-disabled='false']:active {
108
+ background-color: #3443560e;
109
+ }
110
+
111
+ &[aria-disabled='true'] {
112
+ cursor: not-allowed;
113
+ }
25
114
  }
@@ -1,22 +1,35 @@
1
1
  import clsx from 'clsx'
2
- import { ChangeEvent, ComponentPropsWithoutRef, useState } from 'react'
2
+ import { ComponentPropsWithoutRef, ReactNode, useMemo, useState } from 'react'
3
3
 
4
- import { clamp } from '@tremolo-ui/functions'
4
+ import { InputEventOption } from '@tremolo-ui/functions'
5
5
  import { parseValue, Units } from '@tremolo-ui/functions/NumberInput'
6
6
 
7
+ import {
8
+ NumberInputProvider,
9
+ safeClamp,
10
+ useNumberInputContext,
11
+ } from './context'
12
+
13
+ /*
14
+ TODO
15
+ - onBlurでは、parse + (clamp) + formatして返す
16
+ - numberMode: focusが当たった時だけ、parse + type="number"にする
17
+ */
18
+
19
+ /** @category NumberInput */
7
20
  export interface NumberInputProps {
8
21
  value: number | string
9
22
 
23
+ /**
24
+ * @default Number.MIN_SAFE_INTEGER
25
+ */
10
26
  min?: number
27
+ /**
28
+ * @default Number.MAX_SAFE_INTEGER
29
+ */
11
30
  max?: number
12
31
  step?: number
13
- defaultValue?: number
14
-
15
- // wrapping html input
16
- disabled?: boolean
17
- placeholder?: string
18
- readOnly?: boolean
19
- tabIndex?: number
32
+ defaultValue?: number | string
20
33
 
21
34
  /**
22
35
  * @example
@@ -26,11 +39,6 @@ export interface NumberInputProps {
26
39
  */
27
40
  units?: string | Units
28
41
 
29
- /**
30
- * NOTE: If units is specified, it is forced to `false`
31
- */
32
- typeNumber?: boolean
33
-
34
42
  /**
35
43
  * Digits for rounding numbers.
36
44
  * @example
@@ -41,76 +49,94 @@ export interface NumberInputProps {
41
49
  * value=100 -> 100Hz, value=1600 -> 1.60Hz
42
50
  */
43
51
  digit?: number
52
+
44
53
  selectWithFocus?: 'all' | 'number' | 'none'
45
- blurWithEnter?: boolean
46
- className?: string
47
- onChange?: (value: number, event: ChangeEvent<HTMLInputElement>) => void
54
+ blurOnEnter?: boolean
55
+ keepWithinRange?: boolean
56
+ clampValueOnBlur?: boolean
57
+
58
+ // TODO
59
+ /**
60
+ * wheel control option
61
+ */
62
+ wheel?: InputEventOption | null
63
+ /**
64
+ * keyboard control option
65
+ */
66
+ keyboard?: InputEventOption | null
67
+
68
+ wrapperClassName?: string
69
+
70
+ onChange?: (value: number, text: string) => void
48
71
  onFocus?: (
49
72
  value: number,
73
+ text: string,
50
74
  event: React.FocusEvent<HTMLInputElement, Element>,
51
75
  ) => void
52
76
  onBlur?: (
53
77
  value: number,
78
+ text: string,
54
79
  event: React.FocusEvent<HTMLInputElement, Element>,
55
80
  ) => void
81
+
82
+ children?: ReactNode
56
83
  }
57
84
 
58
- /**
59
- * Input with some useful functions for entering numerical values.
60
- */
61
- export function NumberInput({
62
- value,
85
+ function InternalInput({
63
86
  min,
64
87
  max,
65
88
  step,
66
- defaultValue = min,
89
+ defaultValue,
67
90
  units,
68
- disabled = false,
69
- placeholder,
70
- readOnly,
71
- tabIndex,
72
- typeNumber = false,
91
+ disabled,
73
92
  digit,
74
- selectWithFocus = 'none',
75
- blurWithEnter = true,
93
+ selectWithFocus,
94
+ blurOnEnter,
95
+ keepWithinRange,
96
+ clampValueOnBlur,
97
+ wheel,
98
+ keyboard,
99
+ wrapperClassName,
76
100
  className,
77
101
  onChange,
78
102
  onFocus,
79
103
  onBlur,
104
+ children: _children,
80
105
  ...props
81
- }: NumberInputProps &
106
+ }: Omit<NumberInputProps, 'value'> &
82
107
  Omit<ComponentPropsWithoutRef<'input'>, keyof NumberInputProps | 'type'>) {
83
- const [showValue, setShowValue] = useState(
84
- parseValue(String(value), units, digit, defaultValue).formatValue,
85
- )
86
- const calculatedTypeNumber = units ? false : typeNumber
108
+ const [editing, setEditing] = useState(false)
109
+ const value = useNumberInputContext((s) => s.value)
110
+ const valueAsNumber = useNumberInputContext((s) => s.valueAsNumber)
111
+ const change = useNumberInputContext((s) => s.change)
112
+
113
+ const error = useMemo(() => {
114
+ if (editing) return false
115
+ const v = valueAsNumber
116
+ return (min ?? -Infinity) > v || v > (max ?? Infinity)
117
+ }, [editing, max, min, valueAsNumber])
87
118
 
88
119
  return (
89
120
  <input
90
121
  className={clsx('tremolo-number-input', className)}
91
- value={showValue}
122
+ value={value}
92
123
  min={min}
93
124
  max={max}
94
125
  step={step}
95
- type={calculatedTypeNumber ? 'number' : 'text'}
96
- spellCheck={'false'}
126
+ type="text"
97
127
  disabled={disabled}
98
- placeholder={placeholder}
99
- readOnly={readOnly}
100
- tabIndex={tabIndex}
128
+ tabIndex={-1}
129
+ data-error={error}
101
130
  onChange={(event) => {
102
131
  const v = event.currentTarget.value
103
- setShowValue(v)
104
- const newValue = clamp(
105
- parseValue(v, units, digit, defaultValue).rawValue,
106
- min ?? -Infinity,
107
- max ?? Infinity,
108
- )
109
- if (onChange) onChange(newValue, event)
132
+ change(v)
133
+ const valueAsNumber = parseValue(v, units, digit).rawValue
134
+ onChange?.(valueAsNumber, v)
110
135
  }}
111
136
  onFocus={(event) => {
137
+ setEditing(true)
112
138
  const v = event.currentTarget.value
113
- const parsed = parseValue(v, units, digit, defaultValue)
139
+ const parsed = parseValue(v, units, digit)
114
140
  if (selectWithFocus == 'all') {
115
141
  event.currentTarget.setSelectionRange(0, v.length)
116
142
  } else if (selectWithFocus == 'number') {
@@ -119,17 +145,24 @@ export function NumberInput({
119
145
  parsed.formatValue.length - parsed.unit.length,
120
146
  )
121
147
  }
122
- if (onFocus) onFocus(parsed.rawValue, event)
148
+ onFocus?.(parsed.rawValue, parsed.formatValue, event)
123
149
  }}
124
150
  onBlur={(event) => {
151
+ setEditing(false)
125
152
  // update value
126
- const v = event.currentTarget.value
127
- const parsed = parseValue(v, units, digit, defaultValue)
128
- setShowValue(parsed.formatValue)
129
- if (onBlur) onBlur(parsed.rawValue, event)
153
+ let v = parseValue(event.currentTarget.value, units, digit).rawValue
154
+ if (clampValueOnBlur) {
155
+ v = safeClamp(v, min, max)
156
+ }
157
+ const parsed = parseValue(String(v), units, digit)
158
+ change(parsed.formatValue)
159
+ if (clampValueOnBlur) {
160
+ onChange?.(parsed.rawValue, parsed.formatValue)
161
+ }
162
+ onBlur?.(parsed.rawValue, parsed.formatValue, event)
130
163
  }}
131
164
  onKeyDown={(event) => {
132
- if (blurWithEnter && event.key == 'Enter') {
165
+ if (blurOnEnter && event.key == 'Enter') {
133
166
  event.currentTarget.blur()
134
167
  }
135
168
  }}
@@ -137,3 +170,79 @@ export function NumberInput({
137
170
  />
138
171
  )
139
172
  }
173
+
174
+ /**
175
+ * Input with some useful functions for entering numerical values.
176
+ * @category NumberInput
177
+ */
178
+ export function NumberInput({
179
+ value,
180
+ min,
181
+ max,
182
+ step = 1,
183
+ defaultValue,
184
+ units,
185
+ disabled = false,
186
+ digit,
187
+ selectWithFocus = 'none',
188
+ blurOnEnter = true,
189
+ keepWithinRange = true,
190
+ clampValueOnBlur = true,
191
+ wheel = ['raw', 1],
192
+ keyboard = ['raw', 1],
193
+ wrapperClassName,
194
+ className,
195
+ onChange,
196
+ onFocus,
197
+ onBlur,
198
+ children,
199
+ ...props
200
+ }: NumberInputProps &
201
+ Omit<ComponentPropsWithoutRef<'input'>, keyof NumberInputProps | 'type'>) {
202
+ return (
203
+ <NumberInputProvider
204
+ value={String(value)}
205
+ min={min}
206
+ max={max}
207
+ step={step}
208
+ units={units}
209
+ digit={digit}
210
+ keepWithinRange={keepWithinRange}
211
+ onChange={onChange}
212
+ >
213
+ <div
214
+ className={clsx('tremolo-number-input-wrapper', wrapperClassName)}
215
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
216
+ tabIndex={0}
217
+ data-stepper={!!children}
218
+ >
219
+ <InternalInput
220
+ min={min}
221
+ max={max}
222
+ step={step}
223
+ defaultValue={defaultValue}
224
+ units={units}
225
+ disabled={disabled}
226
+ digit={digit}
227
+ selectWithFocus={selectWithFocus}
228
+ blurOnEnter={blurOnEnter}
229
+ keepWithinRange={keepWithinRange}
230
+ clampValueOnBlur={clampValueOnBlur}
231
+ wheel={wheel}
232
+ keyboard={keyboard}
233
+ wrapperClassName={wrapperClassName}
234
+ className={className}
235
+ onChange={onChange}
236
+ onFocus={onFocus}
237
+ onBlur={onBlur}
238
+ {...props}
239
+ />
240
+ {children}
241
+ </div>
242
+ </NumberInputProvider>
243
+ )
244
+ }
245
+
246
+ export * from './Stepper'
247
+ export * from './IncrementStepper'
248
+ export * from './DecrementStepper'