@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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/dist/index.cjs +2486 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1678 -0
  6. package/dist/index.d.cts.map +1 -0
  7. package/dist/index.d.ts +1678 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +2444 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +78 -0
  12. package/src/components/AnimationCanvas/AnimationCanvas.ts +105 -0
  13. package/src/components/AnimationCanvas/index.ts +5 -0
  14. package/src/components/DropZone/DropZone.ts +43 -0
  15. package/src/components/DropZone/index.ts +1 -0
  16. package/src/components/FileInput/FileInput.ts +82 -0
  17. package/src/components/FileInput/FileInputTrigger.ts +24 -0
  18. package/src/components/FileInput/context.ts +18 -0
  19. package/src/components/FileInput/index.ts +3 -0
  20. package/src/components/Knob/Knob.ts +237 -0
  21. package/src/components/Knob/KnobActiveLine.ts +31 -0
  22. package/src/components/Knob/KnobInactiveLine.ts +36 -0
  23. package/src/components/Knob/KnobSVGRoot.ts +25 -0
  24. package/src/components/Knob/KnobThumb.ts +53 -0
  25. package/src/components/Knob/context.ts +30 -0
  26. package/src/components/Knob/index.ts +6 -0
  27. package/src/components/NumberInput/NumberInput.ts +258 -0
  28. package/src/components/NumberInput/NumberInputDecrementStepper.ts +7 -0
  29. package/src/components/NumberInput/NumberInputField.ts +128 -0
  30. package/src/components/NumberInput/NumberInputIncrementStepper.ts +7 -0
  31. package/src/components/NumberInput/NumberInputStepper.ts +54 -0
  32. package/src/components/NumberInput/context.ts +63 -0
  33. package/src/components/NumberInput/index.ts +6 -0
  34. package/src/components/NumberInput/stepper-button.ts +65 -0
  35. package/src/components/Piano/Piano.ts +243 -0
  36. package/src/components/Piano/index.ts +1 -0
  37. package/src/components/PointsEditor/PointsEditor.ts +146 -0
  38. package/src/components/PointsEditor/PointsEditorBackground.ts +17 -0
  39. package/src/components/PointsEditor/PointsEditorContainer.ts +73 -0
  40. package/src/components/PointsEditor/PointsEditorPoint.ts +253 -0
  41. package/src/components/PointsEditor/PointsEditorSelectionBox.ts +36 -0
  42. package/src/components/PointsEditor/context.ts +39 -0
  43. package/src/components/PointsEditor/index.ts +9 -0
  44. package/src/components/Slider/Slider.ts +224 -0
  45. package/src/components/Slider/SliderMarks.ts +57 -0
  46. package/src/components/Slider/SliderMarksOption.ts +85 -0
  47. package/src/components/Slider/SliderThumb.ts +92 -0
  48. package/src/components/Slider/SliderTrack.ts +50 -0
  49. package/src/components/Slider/context.ts +33 -0
  50. package/src/components/Slider/index.ts +6 -0
  51. package/src/components/XYPad/XYPad.ts +250 -0
  52. package/src/components/XYPad/XYPadArea.ts +20 -0
  53. package/src/components/XYPad/XYPadThumb.ts +105 -0
  54. package/src/components/XYPad/context.ts +33 -0
  55. package/src/components/XYPad/index.ts +4 -0
  56. package/src/components/_util/context.ts +8 -0
  57. package/src/components/_util/placement.ts +36 -0
  58. package/src/components/_util/props.ts +32 -0
  59. package/src/components/_util/useCheckSteps.ts +22 -0
  60. package/src/components/_util/visually-hidden-range-input.ts +16 -0
  61. package/src/composables/useDrag.ts +22 -0
  62. package/src/composables/useDragValue.ts +20 -0
  63. package/src/composables/useDropZone.ts +32 -0
  64. package/src/composables/useInstance.ts +52 -0
  65. package/src/composables/useLongPress.ts +20 -0
  66. package/src/composables/useMIDIAccess.ts +58 -0
  67. package/src/composables/useMIDIInput.ts +33 -0
  68. package/src/composables/useMIDIMessage.ts +30 -0
  69. package/src/composables/useWheel.ts +23 -0
  70. 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,7 @@
1
+ import { stepperButton } from './stepper-button'
2
+
3
+ /** Lowers the value by one `step`, repeating while held. */
4
+ export const NumberInputDecrementStepper = /* @__PURE__ */ stepperButton(
5
+ 'NumberInputDecrementStepper',
6
+ -1,
7
+ )
@@ -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,7 @@
1
+ import { stepperButton } from './stepper-button'
2
+
3
+ /** Raises the value by one `step`, repeating while held. */
4
+ export const NumberInputIncrementStepper = /* @__PURE__ */ stepperButton(
5
+ 'NumberInputIncrementStepper',
6
+ 1,
7
+ )
@@ -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
+ }