@tremolo-ui/vue 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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/index.cjs +2486 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1678 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +1678 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2444 -0
- package/dist/index.js.map +1 -0
- package/package.json +78 -0
- package/src/components/AnimationCanvas/AnimationCanvas.ts +105 -0
- package/src/components/AnimationCanvas/index.ts +5 -0
- package/src/components/DropZone/DropZone.ts +43 -0
- package/src/components/DropZone/index.ts +1 -0
- package/src/components/FileInput/FileInput.ts +82 -0
- package/src/components/FileInput/FileInputTrigger.ts +24 -0
- package/src/components/FileInput/context.ts +18 -0
- package/src/components/FileInput/index.ts +3 -0
- package/src/components/Knob/Knob.ts +237 -0
- package/src/components/Knob/KnobActiveLine.ts +31 -0
- package/src/components/Knob/KnobInactiveLine.ts +36 -0
- package/src/components/Knob/KnobSVGRoot.ts +25 -0
- package/src/components/Knob/KnobThumb.ts +53 -0
- package/src/components/Knob/context.ts +30 -0
- package/src/components/Knob/index.ts +6 -0
- package/src/components/NumberInput/NumberInput.ts +258 -0
- package/src/components/NumberInput/NumberInputDecrementStepper.ts +7 -0
- package/src/components/NumberInput/NumberInputField.ts +128 -0
- package/src/components/NumberInput/NumberInputIncrementStepper.ts +7 -0
- package/src/components/NumberInput/NumberInputStepper.ts +54 -0
- package/src/components/NumberInput/context.ts +63 -0
- package/src/components/NumberInput/index.ts +6 -0
- package/src/components/NumberInput/stepper-button.ts +65 -0
- package/src/components/Piano/Piano.ts +243 -0
- package/src/components/Piano/index.ts +1 -0
- package/src/components/PointsEditor/PointsEditor.ts +146 -0
- package/src/components/PointsEditor/PointsEditorBackground.ts +17 -0
- package/src/components/PointsEditor/PointsEditorContainer.ts +73 -0
- package/src/components/PointsEditor/PointsEditorPoint.ts +253 -0
- package/src/components/PointsEditor/PointsEditorSelectionBox.ts +36 -0
- package/src/components/PointsEditor/context.ts +39 -0
- package/src/components/PointsEditor/index.ts +9 -0
- package/src/components/Slider/Slider.ts +224 -0
- package/src/components/Slider/SliderMarks.ts +57 -0
- package/src/components/Slider/SliderMarksOption.ts +85 -0
- package/src/components/Slider/SliderThumb.ts +92 -0
- package/src/components/Slider/SliderTrack.ts +50 -0
- package/src/components/Slider/context.ts +33 -0
- package/src/components/Slider/index.ts +6 -0
- package/src/components/XYPad/XYPad.ts +250 -0
- package/src/components/XYPad/XYPadArea.ts +20 -0
- package/src/components/XYPad/XYPadThumb.ts +105 -0
- package/src/components/XYPad/context.ts +33 -0
- package/src/components/XYPad/index.ts +4 -0
- package/src/components/_util/context.ts +8 -0
- package/src/components/_util/placement.ts +36 -0
- package/src/components/_util/props.ts +32 -0
- package/src/components/_util/useCheckSteps.ts +22 -0
- package/src/components/_util/visually-hidden-range-input.ts +16 -0
- package/src/composables/useDrag.ts +22 -0
- package/src/composables/useDragValue.ts +20 -0
- package/src/composables/useDropZone.ts +32 -0
- package/src/composables/useInstance.ts +52 -0
- package/src/composables/useLongPress.ts +20 -0
- package/src/composables/useMIDIAccess.ts +58 -0
- package/src/composables/useMIDIInput.ts +33 -0
- package/src/composables/useMIDIMessage.ts +30 -0
- package/src/composables/useWheel.ts +23 -0
- package/src/index.ts +67 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { computed, defineComponent, h, provide, ref, type PropType } from 'vue'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
commitNumberInputText,
|
|
5
|
+
numberInputBounds,
|
|
6
|
+
numberInputRanges,
|
|
7
|
+
nudgeNumberInput,
|
|
8
|
+
parseNumberText,
|
|
9
|
+
wheelDirection,
|
|
10
|
+
} from '@tremolo-ui/dom'
|
|
11
|
+
import { linearScale, type Scale } from '@tremolo-ui/functions'
|
|
12
|
+
|
|
13
|
+
import { useWheel } from '../../composables/useWheel'
|
|
14
|
+
import { dragSensitivityProp, keyboardProp, wheelProp } from '../_util/props'
|
|
15
|
+
import { useCheckSteps } from '../_util/useCheckSteps'
|
|
16
|
+
|
|
17
|
+
import { NumberInputKey } from './context'
|
|
18
|
+
|
|
19
|
+
/** Input with some useful functions for entering numbers. Bind it with `v-model`. */
|
|
20
|
+
export const NumberInput = /* @__PURE__ */ defineComponent({
|
|
21
|
+
name: 'NumberInput',
|
|
22
|
+
props: {
|
|
23
|
+
/** The current value. Bind it with `v-model`. */
|
|
24
|
+
modelValue: { type: Number, required: true },
|
|
25
|
+
/** The lowest value. Leave it out for no lower end. */
|
|
26
|
+
min: Number,
|
|
27
|
+
/** The highest value. Leave it out for no upper end. */
|
|
28
|
+
max: Number,
|
|
29
|
+
/** Granularity of the value. @default 1 */
|
|
30
|
+
step: { type: Number, default: 1 },
|
|
31
|
+
/** How the value is distributed, for a `normalized` amount. */
|
|
32
|
+
scale: { type: Object as PropType<Scale>, default: () => linearScale },
|
|
33
|
+
/** The text shown for a value. @default String */
|
|
34
|
+
format: {
|
|
35
|
+
type: Function as PropType<(value: number) => string>,
|
|
36
|
+
default: String,
|
|
37
|
+
},
|
|
38
|
+
/** Read a value back out of the text. Has to undo `format`. */
|
|
39
|
+
parse: {
|
|
40
|
+
type: Function as PropType<(text: string) => number>,
|
|
41
|
+
default: parseNumberText,
|
|
42
|
+
},
|
|
43
|
+
/** Keep the value within `min` and `max` when committed or stepped. */
|
|
44
|
+
clampValue: { type: Boolean, default: true },
|
|
45
|
+
wheel: wheelProp,
|
|
46
|
+
keyboard: keyboardProp,
|
|
47
|
+
dragSensitivity: dragSensitivityProp,
|
|
48
|
+
/** Pixels of vertical drag on the stepper per `step`. `null` turns it off. */
|
|
49
|
+
drag: { type: Number as PropType<number | null>, default: 1 },
|
|
50
|
+
/** Hide the cursor while dragging the stepper. */
|
|
51
|
+
pointerLock: Boolean,
|
|
52
|
+
/** Select the text when the field takes focus. @default 'none' */
|
|
53
|
+
selectOnFocus: {
|
|
54
|
+
type: String as PropType<'all' | 'number' | 'none'>,
|
|
55
|
+
default: 'none',
|
|
56
|
+
},
|
|
57
|
+
/** Show the plain value while the field has focus. */
|
|
58
|
+
unformatOnFocus: Boolean,
|
|
59
|
+
/** Put the caret back at the same digit after an arrow key step. */
|
|
60
|
+
keepCaretOnStep: Boolean,
|
|
61
|
+
/** Commit and leave the field on Enter. @default true */
|
|
62
|
+
blurOnEnter: { type: Boolean, default: true },
|
|
63
|
+
/** Make the input unchangeable and remove it from the tab order. */
|
|
64
|
+
disabled: Boolean,
|
|
65
|
+
/** Make the value unchangeable while leaving the field focusable. */
|
|
66
|
+
readonly: Boolean,
|
|
67
|
+
},
|
|
68
|
+
emits: {
|
|
69
|
+
'update:modelValue': (value: number) => typeof value === 'number',
|
|
70
|
+
},
|
|
71
|
+
setup(props, { slots, emit, expose }) {
|
|
72
|
+
const root = ref<HTMLDivElement | null>(null)
|
|
73
|
+
/**
|
|
74
|
+
* The text being typed. The only state here: not a copy of the value, but
|
|
75
|
+
* the half-finished entry that has no value to be derived from yet.
|
|
76
|
+
*/
|
|
77
|
+
const draft = ref<string | null>(null)
|
|
78
|
+
let input: HTMLInputElement | null = null
|
|
79
|
+
|
|
80
|
+
const inactive = computed(() => props.disabled || props.readonly)
|
|
81
|
+
const ranges = computed(() =>
|
|
82
|
+
numberInputRanges({
|
|
83
|
+
min: props.min,
|
|
84
|
+
max: props.max,
|
|
85
|
+
step: props.step,
|
|
86
|
+
scale: props.scale,
|
|
87
|
+
clampValue: props.clampValue,
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
const bounds = computed(() =>
|
|
91
|
+
numberInputBounds(props.modelValue, {
|
|
92
|
+
min: props.min,
|
|
93
|
+
max: props.max,
|
|
94
|
+
clampValue: props.clampValue,
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
const text = computed(() => draft.value ?? props.format(props.modelValue))
|
|
98
|
+
|
|
99
|
+
// An open end has no travel to sample, so the check is skipped.
|
|
100
|
+
useCheckSteps(() => ({
|
|
101
|
+
component: 'NumberInput',
|
|
102
|
+
range:
|
|
103
|
+
props.min !== undefined &&
|
|
104
|
+
props.max !== undefined &&
|
|
105
|
+
props.min < props.max
|
|
106
|
+
? {
|
|
107
|
+
min: props.min,
|
|
108
|
+
max: props.max,
|
|
109
|
+
step: props.step,
|
|
110
|
+
scale: props.scale,
|
|
111
|
+
}
|
|
112
|
+
: null,
|
|
113
|
+
keyboard: props.keyboard,
|
|
114
|
+
wheel: props.wheel,
|
|
115
|
+
format: props.format,
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
function changeValue(next: number) {
|
|
119
|
+
if (inactive.value) return
|
|
120
|
+
draft.value = null
|
|
121
|
+
if (next !== props.modelValue) emit('update:modelValue', next)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
provide(NumberInputKey, {
|
|
125
|
+
get value() {
|
|
126
|
+
return props.modelValue
|
|
127
|
+
},
|
|
128
|
+
get min() {
|
|
129
|
+
return props.min
|
|
130
|
+
},
|
|
131
|
+
get max() {
|
|
132
|
+
return props.max
|
|
133
|
+
},
|
|
134
|
+
get step() {
|
|
135
|
+
return props.step
|
|
136
|
+
},
|
|
137
|
+
get disabled() {
|
|
138
|
+
return props.disabled
|
|
139
|
+
},
|
|
140
|
+
get readonly() {
|
|
141
|
+
return props.readonly
|
|
142
|
+
},
|
|
143
|
+
get keyboard() {
|
|
144
|
+
return props.keyboard
|
|
145
|
+
},
|
|
146
|
+
get drag() {
|
|
147
|
+
return props.drag
|
|
148
|
+
},
|
|
149
|
+
get dragSensitivity() {
|
|
150
|
+
return props.dragSensitivity
|
|
151
|
+
},
|
|
152
|
+
get pointerLock() {
|
|
153
|
+
return props.pointerLock
|
|
154
|
+
},
|
|
155
|
+
get selectOnFocus() {
|
|
156
|
+
return props.selectOnFocus
|
|
157
|
+
},
|
|
158
|
+
get unformatOnFocus() {
|
|
159
|
+
return props.unformatOnFocus
|
|
160
|
+
},
|
|
161
|
+
get keepCaretOnStep() {
|
|
162
|
+
return props.keepCaretOnStep
|
|
163
|
+
},
|
|
164
|
+
get blurOnEnter() {
|
|
165
|
+
return props.blurOnEnter
|
|
166
|
+
},
|
|
167
|
+
get text() {
|
|
168
|
+
return text.value
|
|
169
|
+
},
|
|
170
|
+
get editing() {
|
|
171
|
+
return draft.value !== null
|
|
172
|
+
},
|
|
173
|
+
get outOfRange() {
|
|
174
|
+
return draft.value === null && bounds.value.outOfRange
|
|
175
|
+
},
|
|
176
|
+
get atMin() {
|
|
177
|
+
return bounds.value.atMin
|
|
178
|
+
},
|
|
179
|
+
get atMax() {
|
|
180
|
+
return bounds.value.atMax
|
|
181
|
+
},
|
|
182
|
+
get rawRange() {
|
|
183
|
+
return ranges.value.raw
|
|
184
|
+
},
|
|
185
|
+
setDraft: (next) => {
|
|
186
|
+
if (inactive.value) return
|
|
187
|
+
draft.value = next
|
|
188
|
+
// Deliberately unclamped: clamping here would make "1500" impossible
|
|
189
|
+
// to type into an input whose max is 100.
|
|
190
|
+
const parsed = props.parse(next)
|
|
191
|
+
if (Number.isFinite(parsed)) emit('update:modelValue', parsed)
|
|
192
|
+
},
|
|
193
|
+
commitDraft: () => {
|
|
194
|
+
if (draft.value === null || inactive.value) return
|
|
195
|
+
const committed = commitNumberInputText(draft.value, props.parse, {
|
|
196
|
+
min: props.min,
|
|
197
|
+
max: props.max,
|
|
198
|
+
clampValue: props.clampValue,
|
|
199
|
+
})
|
|
200
|
+
// Text with no number is not a value: the field goes back to what it
|
|
201
|
+
// was showing.
|
|
202
|
+
if (committed === null) draft.value = null
|
|
203
|
+
else changeValue(committed)
|
|
204
|
+
},
|
|
205
|
+
changeValue,
|
|
206
|
+
nudge: (direction, option, modifiers) =>
|
|
207
|
+
changeValue(
|
|
208
|
+
nudgeNumberInput(
|
|
209
|
+
props.modelValue,
|
|
210
|
+
direction,
|
|
211
|
+
option,
|
|
212
|
+
ranges.value,
|
|
213
|
+
modifiers,
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
setInput: (element) => {
|
|
217
|
+
input = element
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
useWheel(
|
|
222
|
+
root,
|
|
223
|
+
(event) => {
|
|
224
|
+
const direction = wheelDirection(event)
|
|
225
|
+
if (!props.wheel || inactive.value || direction === null) return
|
|
226
|
+
event.preventDefault()
|
|
227
|
+
changeValue(
|
|
228
|
+
nudgeNumberInput(
|
|
229
|
+
props.modelValue,
|
|
230
|
+
direction,
|
|
231
|
+
props.wheel,
|
|
232
|
+
ranges.value,
|
|
233
|
+
event,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
},
|
|
237
|
+
{ requireFocus: true },
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
expose({
|
|
241
|
+
focus: () => {
|
|
242
|
+
if (!props.disabled) input?.focus()
|
|
243
|
+
},
|
|
244
|
+
blur: () => input?.blur(),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
return () =>
|
|
248
|
+
h(
|
|
249
|
+
'div',
|
|
250
|
+
{
|
|
251
|
+
ref: root,
|
|
252
|
+
'data-disabled': props.disabled ? '' : undefined,
|
|
253
|
+
'data-readonly': props.readonly ? '' : undefined,
|
|
254
|
+
},
|
|
255
|
+
slots.default?.(),
|
|
256
|
+
)
|
|
257
|
+
},
|
|
258
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
defineComponent,
|
|
4
|
+
h,
|
|
5
|
+
nextTick,
|
|
6
|
+
onBeforeUnmount,
|
|
7
|
+
ref,
|
|
8
|
+
watchEffect,
|
|
9
|
+
} from 'vue'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
caretAtDecimalOffset,
|
|
13
|
+
caretDecimalOffset,
|
|
14
|
+
numberSpan,
|
|
15
|
+
} from '@tremolo-ui/dom'
|
|
16
|
+
|
|
17
|
+
import { useNumberInputContext } from './context'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The text field, and the only place the value can be typed. While the user
|
|
21
|
+
* types, their own text stands rather than `format(value)`.
|
|
22
|
+
*/
|
|
23
|
+
export const NumberInputField = /* @__PURE__ */ defineComponent({
|
|
24
|
+
name: 'NumberInputField',
|
|
25
|
+
setup(_, { expose }) {
|
|
26
|
+
const field = useNumberInputContext()
|
|
27
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
28
|
+
const focused = ref(false)
|
|
29
|
+
watchEffect(() => field.setInput(input.value))
|
|
30
|
+
onBeforeUnmount(() => field.setInput(null))
|
|
31
|
+
|
|
32
|
+
// Only until the first keystroke: from then on the draft is the user's
|
|
33
|
+
// own text and stands on its own, formatted or not.
|
|
34
|
+
const shown = computed(() =>
|
|
35
|
+
field.unformatOnFocus && focused.value && !field.editing
|
|
36
|
+
? String(field.value)
|
|
37
|
+
: field.text,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async function select() {
|
|
41
|
+
if (field.selectOnFocus === 'none') return
|
|
42
|
+
// Selecting has to wait for `unformatOnFocus` to swap the text in, or
|
|
43
|
+
// it would cover the wrong characters.
|
|
44
|
+
await nextTick()
|
|
45
|
+
const element = input.value
|
|
46
|
+
if (!element || !focused.value) return
|
|
47
|
+
const span =
|
|
48
|
+
field.selectOnFocus === 'all'
|
|
49
|
+
? { start: 0, end: element.value.length }
|
|
50
|
+
: numberSpan(element.value)
|
|
51
|
+
if (span) element.setSelectionRange(span.start, span.end)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function step(direction: number, event: KeyboardEvent) {
|
|
55
|
+
const element = event.currentTarget as HTMLInputElement
|
|
56
|
+
const from = element.value
|
|
57
|
+
const offset =
|
|
58
|
+
field.keepCaretOnStep && element.selectionStart !== null
|
|
59
|
+
? caretDecimalOffset(from, element.selectionStart)
|
|
60
|
+
: null
|
|
61
|
+
field.nudge(direction, field.keyboard!, event)
|
|
62
|
+
if (offset === null) return
|
|
63
|
+
await nextTick()
|
|
64
|
+
// The step may have been clamped away, leaving the text as it was.
|
|
65
|
+
if (element.value === from) return
|
|
66
|
+
const caret = caretAtDecimalOffset(element.value, offset)
|
|
67
|
+
element.setSelectionRange(caret, caret)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
expose({
|
|
71
|
+
focus: () => input.value?.focus(),
|
|
72
|
+
blur: () => input.value?.blur(),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Not type="number": that brings native spinners and a value the browser
|
|
76
|
+
// parses itself, neither of which survives a unit suffix.
|
|
77
|
+
return () =>
|
|
78
|
+
h('input', {
|
|
79
|
+
ref: input,
|
|
80
|
+
type: 'text',
|
|
81
|
+
inputmode: 'decimal',
|
|
82
|
+
role: 'spinbutton',
|
|
83
|
+
value: shown.value,
|
|
84
|
+
disabled: field.disabled,
|
|
85
|
+
readonly: field.readonly,
|
|
86
|
+
'aria-disabled': field.disabled,
|
|
87
|
+
'aria-readonly': field.readonly,
|
|
88
|
+
'aria-valuenow': field.value,
|
|
89
|
+
'aria-valuemin': field.min,
|
|
90
|
+
'aria-valuemax': field.max,
|
|
91
|
+
// The formatted text even while the plain number is shown: it is the
|
|
92
|
+
// one that says what the value means.
|
|
93
|
+
'aria-valuetext': field.text,
|
|
94
|
+
step: field.step,
|
|
95
|
+
'data-disabled': field.disabled ? '' : undefined,
|
|
96
|
+
'data-readonly': field.readonly ? '' : undefined,
|
|
97
|
+
'data-out-of-range': field.outOfRange ? '' : undefined,
|
|
98
|
+
onInput: (event: Event) =>
|
|
99
|
+
field.setDraft((event.target as HTMLInputElement).value),
|
|
100
|
+
onFocus: () => {
|
|
101
|
+
focused.value = true
|
|
102
|
+
select()
|
|
103
|
+
},
|
|
104
|
+
onBlur: () => {
|
|
105
|
+
focused.value = false
|
|
106
|
+
// Nothing was typed, so there is no draft and this returns at once.
|
|
107
|
+
field.commitDraft()
|
|
108
|
+
},
|
|
109
|
+
onKeydown: (event: KeyboardEvent) => {
|
|
110
|
+
const key = event.key
|
|
111
|
+
if (key === 'Enter') {
|
|
112
|
+
field.commitDraft()
|
|
113
|
+
if (field.blurOnEnter) (event.target as HTMLInputElement).blur()
|
|
114
|
+
} else if (
|
|
115
|
+
field.keyboard &&
|
|
116
|
+
!field.disabled &&
|
|
117
|
+
!field.readonly &&
|
|
118
|
+
(key === 'ArrowUp' || key === 'ArrowDown') &&
|
|
119
|
+
// Arrow keys pick a candidate while an IME is converting.
|
|
120
|
+
!event.isComposing
|
|
121
|
+
) {
|
|
122
|
+
event.preventDefault()
|
|
123
|
+
step(key === 'ArrowUp' ? 1 : -1, event)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
},
|
|
128
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { computed, defineComponent, h, provide, ref, watch } from 'vue'
|
|
2
|
+
|
|
3
|
+
import { createStepperDrag, type StepperDragInstance } from '@tremolo-ui/dom'
|
|
4
|
+
|
|
5
|
+
import { StepperKey, useNumberInputContext } from './context'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The area the steppers sit in, and a drag handle in its own right: dragging
|
|
9
|
+
* it up and down moves the value one `step` every `drag` pixels.
|
|
10
|
+
*/
|
|
11
|
+
export const NumberInputStepper = /* @__PURE__ */ defineComponent({
|
|
12
|
+
name: 'NumberInputStepper',
|
|
13
|
+
setup(_, { slots }) {
|
|
14
|
+
const field = useNumberInputContext()
|
|
15
|
+
const el = ref<HTMLDivElement | null>(null)
|
|
16
|
+
let instance: StepperDragInstance | null = null
|
|
17
|
+
|
|
18
|
+
// Only attached while it can do something: `createDrag` puts
|
|
19
|
+
// `touch-action: none` on the element, and a stepper that cannot be
|
|
20
|
+
// dragged should not stop the page scrolling under a finger.
|
|
21
|
+
const enabled = computed(
|
|
22
|
+
() => field.drag !== null && !field.disabled && !field.readonly,
|
|
23
|
+
)
|
|
24
|
+
const options = () => ({
|
|
25
|
+
range: field.rawRange,
|
|
26
|
+
pixels: field.drag ?? 1,
|
|
27
|
+
sensitivity: field.dragSensitivity,
|
|
28
|
+
pointerLock: field.pointerLock,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
watch(
|
|
32
|
+
[el, enabled],
|
|
33
|
+
([element, on], _, onCleanup) => {
|
|
34
|
+
if (!element || !on) return
|
|
35
|
+
const current = createStepperDrag(element, {
|
|
36
|
+
...options(),
|
|
37
|
+
getValue: () => field.value,
|
|
38
|
+
onChange: (next) => field.changeValue(next),
|
|
39
|
+
})
|
|
40
|
+
instance = current
|
|
41
|
+
onCleanup(() => {
|
|
42
|
+
current.destroy()
|
|
43
|
+
if (instance === current) instance = null
|
|
44
|
+
})
|
|
45
|
+
},
|
|
46
|
+
{ immediate: true, flush: 'post' },
|
|
47
|
+
)
|
|
48
|
+
watch(options, (next) => instance?.update(next))
|
|
49
|
+
|
|
50
|
+
provide(StepperKey, { moved: () => instance?.moved() ?? false })
|
|
51
|
+
|
|
52
|
+
return () => h('div', { ref: el }, slots.default?.())
|
|
53
|
+
},
|
|
54
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type InjectionKey } from 'vue'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type InputEventOption,
|
|
5
|
+
type ModifierState,
|
|
6
|
+
type ModifierValue,
|
|
7
|
+
} from '@tremolo-ui/dom'
|
|
8
|
+
import { type ValueRange } from '@tremolo-ui/functions'
|
|
9
|
+
|
|
10
|
+
import { injectContext } from '../_util/context'
|
|
11
|
+
|
|
12
|
+
/** What `NumberInput` shares with its parts. The fields are getters. */
|
|
13
|
+
export interface NumberInputContextValue {
|
|
14
|
+
readonly value: number
|
|
15
|
+
readonly min: number | undefined
|
|
16
|
+
readonly max: number | undefined
|
|
17
|
+
readonly step: number
|
|
18
|
+
readonly disabled: boolean
|
|
19
|
+
readonly readonly: boolean
|
|
20
|
+
readonly keyboard: ModifierValue<InputEventOption> | null
|
|
21
|
+
readonly drag: number | null
|
|
22
|
+
readonly dragSensitivity: ModifierValue<number>
|
|
23
|
+
readonly pointerLock: boolean
|
|
24
|
+
readonly selectOnFocus: 'all' | 'number' | 'none'
|
|
25
|
+
readonly unformatOnFocus: boolean
|
|
26
|
+
readonly keepCaretOnStep: boolean
|
|
27
|
+
readonly blurOnEnter: boolean
|
|
28
|
+
/** The text the field shows: the draft while typing, else `format(value)`. */
|
|
29
|
+
readonly text: string
|
|
30
|
+
/** Whether the user has typed since the value was last committed. */
|
|
31
|
+
readonly editing: boolean
|
|
32
|
+
readonly outOfRange: boolean
|
|
33
|
+
readonly atMin: boolean
|
|
34
|
+
readonly atMax: boolean
|
|
35
|
+
/** The range a raw amount moves across, for the stepper drag. */
|
|
36
|
+
readonly rawRange: ValueRange
|
|
37
|
+
/** Keep typed text as the draft, reporting the number in it. */
|
|
38
|
+
setDraft: (text: string) => void
|
|
39
|
+
/** Commit the draft, clamped, or drop it when it holds no number. */
|
|
40
|
+
commitDraft: () => void
|
|
41
|
+
/** Set the value, dropping any draft. */
|
|
42
|
+
changeValue: (value: number) => void
|
|
43
|
+
/** Move the value by one press of `option`. */
|
|
44
|
+
nudge: (
|
|
45
|
+
direction: number,
|
|
46
|
+
option: ModifierValue<InputEventOption>,
|
|
47
|
+
modifiers?: ModifierState,
|
|
48
|
+
) => void
|
|
49
|
+
/** @internal `NumberInputField` registers the element focus() moves to. */
|
|
50
|
+
setInput: (input: HTMLInputElement | null) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const NumberInputKey: InjectionKey<NumberInputContextValue> =
|
|
54
|
+
Symbol('NumberInput')
|
|
55
|
+
|
|
56
|
+
/** The context of the enclosing `NumberInput`, for a part of your own. */
|
|
57
|
+
export function useNumberInputContext(): NumberInputContextValue {
|
|
58
|
+
return injectContext(NumberInputKey, 'NumberInput')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @internal Whether the drag on `NumberInputStepper` has moved the value. */
|
|
62
|
+
export const StepperKey: InjectionKey<{ moved: () => boolean }> =
|
|
63
|
+
Symbol('NumberInputStepper')
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useNumberInputContext, type NumberInputContextValue } from './context'
|
|
2
|
+
export { NumberInput } from './NumberInput'
|
|
3
|
+
export { NumberInputDecrementStepper } from './NumberInputDecrementStepper'
|
|
4
|
+
export { NumberInputField } from './NumberInputField'
|
|
5
|
+
export { NumberInputIncrementStepper } from './NumberInputIncrementStepper'
|
|
6
|
+
export { NumberInputStepper } from './NumberInputStepper'
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { computed, defineComponent, h, inject } from 'vue'
|
|
2
|
+
|
|
3
|
+
import { useLongPress } from '../../composables/useLongPress'
|
|
4
|
+
|
|
5
|
+
import { StepperKey, useNumberInputContext } from './context'
|
|
6
|
+
|
|
7
|
+
/** The body of `NumberInputIncrementStepper` and `NumberInputDecrementStepper`. */
|
|
8
|
+
export function stepperButton(name: string, direction: 1 | -1) {
|
|
9
|
+
return defineComponent({
|
|
10
|
+
name,
|
|
11
|
+
setup(_, { slots }) {
|
|
12
|
+
const field = useNumberInputContext()
|
|
13
|
+
const stepper = inject(StepperKey, null)
|
|
14
|
+
const blocked = computed(
|
|
15
|
+
() => field.disabled || (direction > 0 ? field.atMax : field.atMin),
|
|
16
|
+
)
|
|
17
|
+
const press = useLongPress({
|
|
18
|
+
onPress: () => {
|
|
19
|
+
if (field.disabled || field.readonly) return
|
|
20
|
+
// Once the pointer has actually travelled, the drag on the stepper
|
|
21
|
+
// owns the value; repeating on top of it would move it twice.
|
|
22
|
+
if (stepper?.moved()) return
|
|
23
|
+
field.nudge(direction, ['raw', field.step])
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
// A bare arrow has no accessible name of its own. Overridable, since a
|
|
27
|
+
// caller may need it in their own language. `role="button"` does not
|
|
28
|
+
// take aria-readonly, so that state reaches the styles through the data
|
|
29
|
+
// attribute alone.
|
|
30
|
+
return () =>
|
|
31
|
+
h(
|
|
32
|
+
'div',
|
|
33
|
+
{
|
|
34
|
+
role: 'button',
|
|
35
|
+
tabindex: -1,
|
|
36
|
+
'aria-label': direction > 0 ? 'Increment' : 'Decrement',
|
|
37
|
+
'aria-disabled': blocked.value,
|
|
38
|
+
'data-disabled': blocked.value ? '' : undefined,
|
|
39
|
+
'data-readonly': field.readonly ? '' : undefined,
|
|
40
|
+
onPointerdown: (event: PointerEvent) => press(event),
|
|
41
|
+
},
|
|
42
|
+
slots.default
|
|
43
|
+
? slots.default()
|
|
44
|
+
: h(
|
|
45
|
+
'svg',
|
|
46
|
+
{
|
|
47
|
+
style: {
|
|
48
|
+
width: 'var(--stepper-icon-size)',
|
|
49
|
+
height: 'var(--stepper-icon-size)',
|
|
50
|
+
},
|
|
51
|
+
viewBox: '0 0 24 24',
|
|
52
|
+
fill: 'none',
|
|
53
|
+
stroke: 'currentColor',
|
|
54
|
+
'stroke-width': 2,
|
|
55
|
+
'stroke-linecap': 'round',
|
|
56
|
+
'stroke-linejoin': 'round',
|
|
57
|
+
},
|
|
58
|
+
h('polyline', {
|
|
59
|
+
points: direction > 0 ? '18 15 12 9 6 15' : '6 9 12 15 18 9',
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
}
|