@tremolo-ui/react 0.5.0 → 0.7.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 (86) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +1669 -541
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1571 -486
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1571 -486
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1640 -538
  16. package/dist/index.js.map +1 -1
  17. package/package.json +29 -27
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/DropZone/index.tsx +100 -0
  20. package/src/components/FileInput/Trigger.tsx +39 -0
  21. package/src/components/FileInput/context.tsx +24 -0
  22. package/src/components/FileInput/index.tsx +144 -0
  23. package/src/components/Knob/ActiveLine.tsx +7 -6
  24. package/src/components/Knob/InactiveLine.tsx +9 -10
  25. package/src/components/Knob/SVGRoot.tsx +5 -3
  26. package/src/components/Knob/Thumb.tsx +33 -11
  27. package/src/components/Knob/context.tsx +17 -1
  28. package/src/components/Knob/index.tsx +156 -60
  29. package/src/components/NumberInput/DecrementStepper.tsx +3 -10
  30. package/src/components/NumberInput/IncrementStepper.tsx +3 -10
  31. package/src/components/NumberInput/InputField.tsx +185 -108
  32. package/src/components/NumberInput/Stepper.tsx +120 -84
  33. package/src/components/NumberInput/StepperButton.tsx +100 -0
  34. package/src/components/NumberInput/context.tsx +34 -8
  35. package/src/components/NumberInput/index.tsx +270 -82
  36. package/src/components/Piano/index.tsx +363 -200
  37. package/src/components/PointsEditor/Background.tsx +13 -10
  38. package/src/components/PointsEditor/Container.tsx +83 -29
  39. package/src/components/PointsEditor/Point.tsx +241 -79
  40. package/src/components/PointsEditor/SelectionBox.tsx +48 -0
  41. package/src/components/PointsEditor/context.tsx +78 -10
  42. package/src/components/PointsEditor/index.tsx +440 -41
  43. package/src/components/Slider/Marks.tsx +48 -19
  44. package/src/components/Slider/MarksOption.tsx +64 -45
  45. package/src/components/Slider/Thumb.tsx +107 -55
  46. package/src/components/Slider/Track.tsx +66 -62
  47. package/src/components/Slider/context.tsx +4 -2
  48. package/src/components/Slider/index.tsx +136 -77
  49. package/src/components/Slider/type.ts +29 -9
  50. package/src/components/XYPad/Area.tsx +46 -41
  51. package/src/components/XYPad/Thumb.tsx +131 -64
  52. package/src/components/XYPad/context.tsx +4 -1
  53. package/src/components/XYPad/index.tsx +183 -53
  54. package/src/components/_util/Placement.tsx +68 -0
  55. package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
  56. package/src/components/_util/css-length.ts +13 -0
  57. package/src/components/_util/decimal-digits.ts +23 -0
  58. package/src/components/_util/visually-hidden.ts +25 -0
  59. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  60. package/src/css-variables.ts +23 -0
  61. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  62. package/src/hooks/_internal/useCheckSteps.ts +169 -0
  63. package/src/hooks/useAnimationFrame.ts +17 -7
  64. package/src/hooks/useDrag.ts +51 -12
  65. package/src/hooks/useDragValue.ts +71 -11
  66. package/src/hooks/useDropZone.ts +78 -0
  67. package/src/hooks/useEventListener.ts +10 -6
  68. package/src/hooks/useLongPress.ts +42 -8
  69. package/src/hooks/useMIDIAccess.ts +82 -11
  70. package/src/hooks/useMIDIInput.ts +45 -13
  71. package/src/hooks/useMIDIMessage.ts +23 -5
  72. package/src/hooks/useWheel.ts +4 -1
  73. package/src/index.ts +45 -25
  74. package/src/input-event.ts +32 -0
  75. package/dist/index.css +0 -501
  76. package/src/Globals.d.ts +0 -3
  77. package/src/components/Knob/index.css +0 -81
  78. package/src/components/NumberInput/index.css +0 -121
  79. package/src/components/NumberInput/stepperButton.tsx +0 -90
  80. package/src/components/Piano/index.css +0 -90
  81. package/src/components/PointsEditor/index.css +0 -35
  82. package/src/components/Slider/index.css +0 -125
  83. package/src/components/XYPad/index.css +0 -60
  84. package/src/components/_util/index.ts +0 -28
  85. package/src/styles/global.css +0 -19
  86. /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
@@ -1,123 +1,200 @@
1
- import clsx from 'clsx'
2
- import { ComponentPropsWithoutRef, CSSProperties, Ref } from 'react'
1
+ import {
2
+ ComponentPropsWithoutRef,
3
+ forwardRef,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
3
9
 
4
- import { useComposedRefs } from '../_util/composeRefs'
10
+ import { useComposedRefs } from '../../compose-refs'
5
11
 
6
12
  import { useNumberInputContext } from './context'
7
13
 
8
- export interface NumberInputFieldProps {
9
- /**
10
- * Select the text when the input takes focus. `'number'` selects the leading
11
- * number, leaving whatever the format appended to it.
12
- * @default 'none'
13
- */
14
- selectOnFocus?: 'all' | 'number' | 'none'
15
- /**
16
- * Commit and leave the input when Enter is pressed. Enter commits either way.
17
- * @default true
18
- */
19
- blurOnEnter?: boolean
20
-
21
- className?: string
22
- style?: CSSProperties
23
- ref?: Ref<HTMLInputElement>
24
- }
14
+ // The value belongs to `NumberInput.Root`, and the field is always text.
15
+ type Props = Omit<
16
+ ComponentPropsWithoutRef<'input'>,
17
+ 'type' | 'value' | 'defaultValue'
18
+ >
25
19
 
26
20
  /** The leading number of the displayed text, whatever the format put around it. */
27
21
  const NUMBER_PREFIX = /^\s*-?[\d.,]*/
28
22
 
23
+ const numberEnd = (text: string) =>
24
+ text.match(NUMBER_PREFIX)?.[0].length ?? text.length
25
+
26
+ /**
27
+ * The index the caret is measured against: the decimal point, or where one
28
+ * would go if the number has none.
29
+ *
30
+ * Measuring from an end instead would slide the caret across a digit whenever
31
+ * the number changed length — `9.9` to `10.0` gains a character in front, `10`
32
+ * to `9` loses one — which is exactly what stepping does.
33
+ */
34
+ const decimalAnchor = (text: string) => {
35
+ const dot = text.indexOf('.')
36
+ return dot === -1 ? numberEnd(text) : dot
37
+ }
38
+
29
39
  /**
30
40
  * The text field of a `NumberInput`, and the only place the value can be typed.
31
41
  *
32
42
  * While the user types, their own text stands rather than `format(value)`, so
33
43
  * that a half-finished entry is not rewritten under the caret.
44
+ *
45
+ * How it behaves on focus, Enter and the arrow keys is set on `Root`
46
+ * (`selectOnFocus`, `unformatOnFocus`, `keepCaretOnStep`, `blurOnEnter`).
34
47
  */
35
- export function InputField({
36
- selectOnFocus = 'none',
37
- blurOnEnter = true,
38
- className,
39
- style,
40
- ref,
41
- onFocus,
42
- onBlur,
43
- onKeyDown,
44
- ...props
45
- }: NumberInputFieldProps &
46
- Omit<
47
- ComponentPropsWithoutRef<'input'>,
48
- keyof NumberInputFieldProps | 'type' | 'value' | 'defaultValue'
49
- >) {
50
- const {
51
- value,
52
- min,
53
- max,
54
- step,
55
- disabled,
56
- readonly,
57
- keyboard,
58
- text,
59
- outOfRange,
60
- setDraft,
61
- commitDraft,
62
- nudge,
63
- inputRef,
64
- } = useNumberInputContext()
65
-
66
- const composedRef = useComposedRefs<HTMLInputElement>(ref, inputRef)
67
-
68
- return (
69
- <input
70
- ref={composedRef}
71
- className={clsx('tremolo-number-input-field', className)}
72
- // Not type="number": that brings native spinners and a value the browser
73
- // parses itself, neither of which survives a unit suffix.
74
- type="text"
75
- inputMode="decimal"
76
- role="spinbutton"
77
- value={text}
78
- readOnly={readonly}
79
- aria-disabled={disabled}
80
- aria-readonly={readonly}
81
- aria-valuenow={value}
82
- aria-valuemin={min}
83
- aria-valuemax={max}
84
- aria-valuetext={text}
85
- step={step}
86
- data-out-of-range={outOfRange}
87
- style={style}
88
- onChange={(event) => setDraft(event.currentTarget.value)}
89
- onFocus={(event) => {
90
- const input = event.currentTarget
91
- if (selectOnFocus == 'all') {
92
- input.setSelectionRange(0, input.value.length)
93
- } else if (selectOnFocus == 'number') {
94
- input.setSelectionRange(
95
- 0,
96
- input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
97
- )
98
- }
99
- onFocus?.(event)
100
- }}
101
- onBlur={(event) => {
102
- commitDraft()
103
- onBlur?.(event)
104
- }}
105
- onKeyDown={(event) => {
106
- const key = event.key
107
- if (key == 'Enter') {
48
+ export const InputField = /* @__PURE__ */ forwardRef<HTMLInputElement, Props>(
49
+ function InputField(
50
+ { className, style, onFocus, onBlur, onKeyDown, ...props },
51
+ forwardedRef,
52
+ ) {
53
+ const {
54
+ value,
55
+ min,
56
+ max,
57
+ step,
58
+ disabled,
59
+ readonly,
60
+ keyboard,
61
+ selectOnFocus,
62
+ unformatOnFocus,
63
+ keepCaretOnStep,
64
+ blurOnEnter,
65
+ text,
66
+ editing,
67
+ outOfRange,
68
+ setDraft,
69
+ commitDraft,
70
+ nudge,
71
+ inputRef,
72
+ } = useNumberInputContext()
73
+
74
+ const [focused, setFocused] = useState(false)
75
+
76
+ // Only until the first keystroke: from then on the draft is the user's own
77
+ // text and stands on its own, formatted or not.
78
+ const shown = unformatOnFocus && focused && !editing ? String(value) : text
79
+
80
+ // Selecting has to wait for that swap. Called from the focus handler it
81
+ // would run against the formatted text still in the input and cover the
82
+ // wrong characters.
83
+ useEffect(() => {
84
+ if (!focused || selectOnFocus === 'none') return
85
+ const input = inputRef.current
86
+ if (!input) return
87
+ if (selectOnFocus === 'all') {
88
+ input.setSelectionRange(0, input.value.length)
89
+ } else {
90
+ input.setSelectionRange(
91
+ 0,
92
+ input.value.match(NUMBER_PREFIX)?.[0].length ?? 0,
93
+ )
94
+ }
95
+ // Deliberately not re-run as the text changes: that would drag the
96
+ // selection back over what the user is typing.
97
+ }, [focused, selectOnFocus, inputRef])
98
+
99
+ /**
100
+ * Where to put the caret once the stepped value has been rendered, and the
101
+ * text it was measured against.
102
+ */
103
+ const caretAfterStep = useRef<{ offset: number; from: string } | null>(null)
104
+
105
+ // A layout effect rather than a plain one: the caret is moved before the
106
+ // browser paints, so it is never seen at the end of the text first.
107
+ useLayoutEffect(() => {
108
+ const pending = caretAfterStep.current
109
+ if (!pending) return
110
+ caretAfterStep.current = null
111
+
112
+ const input = inputRef.current
113
+ // The step may have been clamped away, leaving the text as it was. The
114
+ // caret has not moved either, since the key press was prevented.
115
+ if (!input || input.value === pending.from) return
116
+
117
+ const place = decimalAnchor(input.value) + pending.offset
118
+ const caret = Math.max(0, Math.min(place, numberEnd(input.value)))
119
+ input.setSelectionRange(caret, caret)
120
+ })
121
+
122
+ const composedRef = useComposedRefs<HTMLInputElement>(
123
+ forwardedRef,
124
+ inputRef,
125
+ )
126
+
127
+ return (
128
+ <input
129
+ ref={composedRef}
130
+ className={className}
131
+ // Not type="number": that brings native spinners and a value the browser
132
+ // parses itself, neither of which survives a unit suffix.
133
+ type="text"
134
+ inputMode="decimal"
135
+ role="spinbutton"
136
+ value={shown}
137
+ disabled={disabled}
138
+ readOnly={readonly}
139
+ aria-disabled={disabled}
140
+ aria-readonly={readonly}
141
+ data-disabled={disabled ? '' : undefined}
142
+ data-readonly={readonly ? '' : undefined}
143
+ aria-valuenow={value}
144
+ aria-valuemin={min}
145
+ aria-valuemax={max}
146
+ // The formatted text, even while the field shows the plain number: it is
147
+ // the one that says what the value means.
148
+ aria-valuetext={text}
149
+ step={step}
150
+ data-out-of-range={outOfRange ? '' : undefined}
151
+ style={style}
152
+ onChange={(event) => {
153
+ // The text is the user's own now, so a caret measured against the
154
+ // stepped one no longer means anything.
155
+ caretAfterStep.current = null
156
+ setDraft(event.currentTarget.value)
157
+ }}
158
+ onFocus={(event) => {
159
+ setFocused(true)
160
+ onFocus?.(event)
161
+ }}
162
+ onBlur={(event) => {
163
+ setFocused(false)
164
+ // Nothing was typed, so there is no draft and this returns at once —
165
+ // taking focus and leaving again never commits anything.
108
166
  commitDraft()
109
- if (blurOnEnter) event.currentTarget.blur()
110
- } else if (
111
- keyboard &&
112
- !readonly &&
113
- (key == 'ArrowUp' || key == 'ArrowDown')
114
- ) {
115
- event.preventDefault()
116
- nudge(key == 'ArrowUp' ? 1 : -1, keyboard)
117
- }
118
- onKeyDown?.(event)
119
- }}
120
- {...props}
121
- />
122
- )
123
- }
167
+ onBlur?.(event)
168
+ }}
169
+ onKeyDown={(event) => {
170
+ const key = event.key
171
+ if (key === 'Enter') {
172
+ commitDraft()
173
+ if (blurOnEnter) event.currentTarget.blur()
174
+ } else if (
175
+ keyboard &&
176
+ !disabled &&
177
+ !readonly &&
178
+ (key === 'ArrowUp' || key === 'ArrowDown') &&
179
+ // Arrow keys pick a candidate while an IME is converting. Stepping
180
+ // the value there would fight the conversion, and moving the caret
181
+ // would break it outright.
182
+ !event.nativeEvent.isComposing
183
+ ) {
184
+ event.preventDefault()
185
+ const input = event.currentTarget
186
+ if (keepCaretOnStep && input.selectionStart !== null) {
187
+ caretAfterStep.current = {
188
+ offset: input.selectionStart - decimalAnchor(input.value),
189
+ from: input.value,
190
+ }
191
+ }
192
+ nudge(key === 'ArrowUp' ? 1 : -1, keyboard, event)
193
+ }
194
+ onKeyDown?.(event)
195
+ }}
196
+ {...props}
197
+ />
198
+ )
199
+ },
200
+ )
@@ -1,27 +1,18 @@
1
- import clsx from 'clsx'
2
- import {
3
- ComponentPropsWithoutRef,
4
- CSSProperties,
5
- ReactNode,
6
- Ref,
7
- useMemo,
8
- useRef,
9
- } from 'react'
1
+ import { ComponentPropsWithoutRef, forwardRef, useMemo, useRef } from 'react'
10
2
 
11
- import { applyDelta } from '@tremolo-ui/functions'
3
+ import {
4
+ applyDelta,
5
+ type InputEventOption,
6
+ mapModifier,
7
+ selectModifier,
8
+ } from '@tremolo-ui/dom'
12
9
 
10
+ import { useComposedRefs } from '../../compose-refs'
13
11
  import { useDrag } from '../../hooks/useDrag'
14
- import { useComposedRefs } from '../_util/composeRefs'
15
12
 
16
13
  import { StepperProvider, useNumberInputContext } from './context'
17
14
 
18
- export interface StepperProps {
19
- className?: string
20
- style?: CSSProperties
21
- /** `<NumberInput.IncrementStepper />` and `<NumberInput.DecrementStepper />` go here. */
22
- children?: ReactNode
23
- ref?: Ref<HTMLDivElement>
24
- }
15
+ type Props = ComponentPropsWithoutRef<'div'>
25
16
 
26
17
  /**
27
18
  * The area the steppers sit in, and a drag handle in its own right: dragging it
@@ -30,74 +21,119 @@ export interface StepperProps {
30
21
  * The drag lives here rather than on `InputField` because `createDrag` turns
31
22
  * off text selection on whatever element it is attached to.
32
23
  */
33
- export function Stepper({
34
- className,
35
- style,
36
- children,
37
- ref,
38
- ...props
39
- }: StepperProps & Omit<ComponentPropsWithoutRef<'div'>, keyof StepperProps>) {
40
- const { value, step, readonly, drag, range, changeValue } =
41
- useNumberInputContext()
24
+ export const Stepper = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
25
+ function Stepper({ className, style, children, ...props }, forwardedRef) {
26
+ const {
27
+ value,
28
+ step,
29
+ disabled,
30
+ readonly,
31
+ drag,
32
+ dragSensitivity,
33
+ pointerLock,
34
+ rawRange,
35
+ changeValue,
36
+ } = useNumberInputContext()
37
+ const inactive = disabled || readonly
38
+
39
+ /**
40
+ * The sensitivity as an amount per `drag` pixels, so that the drag goes
41
+ * through the same `applyDelta` the wheel and the arrow keys do. Carrying
42
+ * the modifier map over rather than resolving it here is what keeps `step`
43
+ * out of the pipeline for a modifier entry — naming one is a request to move
44
+ * off the grid.
45
+ */
46
+ const dragOptions = useMemo(
47
+ () =>
48
+ mapModifier(dragSensitivity, (factor): InputEventOption => [
49
+ 'raw',
50
+ step * factor,
51
+ ]),
52
+ [dragSensitivity, step],
53
+ )
54
+
55
+ /**
56
+ * Set once the drag has actually moved the value, so that the press-and-hold
57
+ * repeat of a stepper stands down and leaves the value to the drag. A ref,
58
+ * because it is read from inside the repeat rather than rendered.
59
+ */
60
+ const draggingRef = useRef(false)
61
+ /**
62
+ * Where the drag started, taken on the first move rather than on pointer
63
+ * down: the steppers act on pointer down, so by then the value may already
64
+ * have been nudged once, and the drag should carry on from there.
65
+ */
66
+ const originRef = useRef<{ y: number; value: number } | null>(null)
67
+ /**
68
+ * Which modifier the drag is currently counting at, and where the previous
69
+ * event was — a key produces no pointer event of its own, so a change is
70
+ * only seen on the next move and has to be dated back to the one before it.
71
+ */
72
+ const factorRef = useRef<number>(1)
73
+ const previousYRef = useRef(0)
74
+
75
+ const dragRefCallback = useDrag<HTMLDivElement>({
76
+ threshold: 1,
77
+ cursor: inactive ? undefined : 'ns-resize',
78
+ pointerLock: inactive ? false : pointerLock,
79
+ onDragStart: (state) => {
80
+ originRef.current = null
81
+ draggingRef.current = false
82
+ factorRef.current = selectModifier(dragSensitivity, state.event).value
83
+ },
84
+ onDrag: (_x, y, _dx, _dy, state) => {
85
+ if (inactive || drag === null) return
86
+ if (!originRef.current) {
87
+ originRef.current = { y, value }
88
+ previousYRef.current = y
89
+ return
90
+ }
42
91
 
43
- /**
44
- * Set once the drag has actually moved the value, so that the press-and-hold
45
- * repeat of a stepper stands down and leaves the value to the drag. A ref,
46
- * because it is read from inside the repeat rather than rendered.
47
- */
48
- const draggingRef = useRef(false)
49
- /**
50
- * Where the drag started, taken on the first move rather than on pointer
51
- * down: the steppers act on pointer down, so by then the value may already
52
- * have been nudged once, and the drag should carry on from there.
53
- */
54
- const originRef = useRef<{ y: number; value: number } | null>(null)
92
+ // Pressing or releasing the key mid-drag must not move the value, so the
93
+ // travel so far is folded into the origin and measuring starts again
94
+ // from the previous event: that event's own distance belongs to the new
95
+ // sensitivity.
96
+ const factor = selectModifier(dragSensitivity, state.event).value
97
+ if (factor !== factorRef.current) {
98
+ originRef.current = { y: previousYRef.current, value }
99
+ factorRef.current = factor
100
+ }
101
+ previousYRef.current = y
55
102
 
56
- const dragRefCallback = useDrag<HTMLDivElement>({
57
- threshold: 1,
58
- cursor: readonly ? undefined : 'ns-resize',
59
- onDragStart: () => {
60
- originRef.current = null
61
- draggingRef.current = false
62
- },
63
- onDrag: (_x, y) => {
64
- if (readonly || drag == null) return
65
- if (!originRef.current) {
66
- originRef.current = { y, value }
67
- return
68
- }
69
- // Dragging up raises the value, as on a knob.
70
- const steps = Math.round(-(y - originRef.current.y) / drag)
71
- if (steps == 0) return
72
- draggingRef.current = true
73
- // The same pipeline the wheel and the arrow keys use. Counting from where
74
- // the drag started keeps it from accumulating a rounding error.
75
- changeValue(
76
- applyDelta(originRef.current.value, steps, ['raw', step], range),
77
- )
78
- },
79
- onDragEnd: () => {
80
- draggingRef.current = false
81
- },
82
- })
103
+ // Dragging up raises the value, as on a knob.
104
+ const steps = Math.round(-(y - originRef.current.y) / drag)
105
+ if (steps === 0) return
106
+ draggingRef.current = true
107
+ // The same pipeline the wheel and the arrow keys use. Counting from where
108
+ // the drag started keeps it from accumulating a rounding error.
109
+ changeValue(
110
+ applyDelta(
111
+ originRef.current.value,
112
+ steps,
113
+ dragOptions,
114
+ rawRange,
115
+ state.event,
116
+ ),
117
+ )
118
+ },
119
+ onDragEnd: () => {
120
+ draggingRef.current = false
121
+ },
122
+ })
83
123
 
84
- const composedRef = useComposedRefs<HTMLDivElement>(
85
- ref,
86
- drag != null && !readonly ? dragRefCallback : undefined,
87
- )
124
+ const composedRef = useComposedRefs<HTMLDivElement>(
125
+ forwardedRef,
126
+ drag !== null && !inactive ? dragRefCallback : undefined,
127
+ )
88
128
 
89
- const context = useMemo(() => ({ draggingRef }), [])
129
+ const context = useMemo(() => ({ draggingRef }), [])
90
130
 
91
- return (
92
- <StepperProvider value={context}>
93
- <div
94
- ref={composedRef}
95
- className={clsx('tremolo-number-input-stepper', className)}
96
- style={style}
97
- {...props}
98
- >
99
- {children}
100
- </div>
101
- </StepperProvider>
102
- )
103
- }
131
+ return (
132
+ <StepperProvider value={context}>
133
+ <div ref={composedRef} className={className} style={style} {...props}>
134
+ {children}
135
+ </div>
136
+ </StepperProvider>
137
+ )
138
+ },
139
+ )
@@ -0,0 +1,100 @@
1
+ import { ComponentPropsWithoutRef, ReactNode, CSSProperties } from 'react'
2
+
3
+ import { useLongPress } from '../../hooks/useLongPress'
4
+
5
+ import { useNumberInputContext, useStepperContext } from './context'
6
+
7
+ import type { CSSVariables } from '../../css-variables'
8
+
9
+ /**
10
+ * `--stepper-icon-size` is read by the default arrow inside, and set on the
11
+ * button so that it inherits there.
12
+ */
13
+ export type StepperButtonProps = Omit<
14
+ ComponentPropsWithoutRef<'div'>,
15
+ 'style'
16
+ > & {
17
+ style?: CSSProperties & CSSVariables<'stepper-icon-size'>
18
+ }
19
+
20
+ type Props = StepperButtonProps
21
+
22
+ /**
23
+ * Shared body of `IncrementStepper` and `DecrementStepper`: the two differ only
24
+ * in which way they move the value and which arrow they draw.
25
+ */
26
+ export function StepperButton({
27
+ direction,
28
+ variant,
29
+ icon,
30
+ className,
31
+ style,
32
+ children,
33
+ onPointerDown,
34
+ ...props
35
+ }: Props & {
36
+ direction: 1 | -1
37
+ variant: 'increment' | 'decrement'
38
+ icon: ReactNode
39
+ }) {
40
+ const { step, disabled, readonly, atMin, atMax, nudge } =
41
+ useNumberInputContext()
42
+ const stepper = useStepperContext()
43
+
44
+ // Not memoized: the callback reads `draggingRef.current`, which the compiler
45
+ // cannot line up with a manual dependency list. `useLongPress` only ever
46
+ // calls it through a ref, so a fresh identity per render costs nothing.
47
+ const press = useLongPress(() => {
48
+ // Once the pointer has actually travelled, the drag on `Stepper` owns the
49
+ // value; repeating on top of it would move it twice.
50
+ if (stepper?.draggingRef.current) return
51
+ nudge(direction, ['raw', step])
52
+ })
53
+
54
+ return (
55
+ <div
56
+ className={className}
57
+ role="button"
58
+ tabIndex={-1}
59
+ // A bare arrow has no accessible name of its own. Overridable, since a
60
+ // caller may need it in their own language.
61
+ aria-label={direction > 0 ? 'Increment' : 'Decrement'}
62
+ aria-disabled={disabled || (direction > 0 ? atMax : atMin)}
63
+ // `role="button"` does not take aria-readonly, so the state reaches the
64
+ // styles through the data attribute alone.
65
+ data-disabled={
66
+ disabled || (direction > 0 ? atMax : atMin) ? '' : undefined
67
+ }
68
+ data-readonly={readonly ? '' : undefined}
69
+ style={style}
70
+ onPointerDown={(event) => {
71
+ if (!disabled && !readonly) press(event)
72
+ onPointerDown?.(event)
73
+ }}
74
+ {...props}
75
+ >
76
+ {children ?? icon}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export function StepperArrow({ up }: { up: boolean }) {
82
+ return (
83
+ <svg
84
+ // The arrow the component draws when no children are given, so it sizes
85
+ // itself rather than asking a stylesheet to.
86
+ style={{
87
+ width: 'var(--stepper-icon-size)',
88
+ height: 'var(--stepper-icon-size)',
89
+ }}
90
+ viewBox="0 0 24 24"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ strokeWidth="2"
94
+ strokeLinecap="round"
95
+ strokeLinejoin="round"
96
+ >
97
+ <polyline points={up ? '18 15 12 9 6 15' : '6 9 12 15 18 9'} />
98
+ </svg>
99
+ )
100
+ }