@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,85 @@
1
+ import { computed, defineComponent, h, type PropType } from 'vue'
2
+
3
+ import { cssLength, valuePercent } from '@tremolo-ui/dom'
4
+
5
+ import { checkPlacement } from '../_util/placement'
6
+
7
+ import { useSliderContext } from './context'
8
+
9
+ /** One mark along the slider, and its label. */
10
+ export const SliderMarksOption = /* @__PURE__ */ defineComponent({
11
+ name: 'SliderMarksOption',
12
+ props: {
13
+ /** Where the mark sits, on the same scale as the thumb. */
14
+ value: { type: Number, required: true },
15
+ /** Draw the mark itself. @default true */
16
+ mark: { type: Boolean, default: true },
17
+ /**
18
+ * Text shown next to the mark. Leave it out to show the value; `null`
19
+ * leaves the label out.
20
+ */
21
+ label: {
22
+ type: [Number, String, null] as PropType<number | string | null>,
23
+ default: undefined,
24
+ },
25
+ /** Thickness of the mark. Sets `--thickness`. */
26
+ thickness: [Number, String],
27
+ /** Length of the mark. Sets `--length`. */
28
+ length: [Number, String],
29
+ /** Space between the mark and the label. Sets `--gap`. */
30
+ gap: [Number, String],
31
+ /** Classes for the mark and the label inside the option. */
32
+ classes: Object as PropType<{ mark?: string; label?: string }>,
33
+ /** Styles for the mark and the label inside the option. */
34
+ styles: Object as PropType<{ mark?: string; label?: string }>,
35
+ },
36
+ setup(props) {
37
+ const slider = useSliderContext()
38
+ checkPlacement('SliderMarksOption', 'SliderMarks')
39
+ // The marks sit on the same curve the thumb runs along.
40
+ const percent = computed(() =>
41
+ valuePercent(
42
+ props.value,
43
+ { min: slider.min, max: slider.max, scale: slider.scale },
44
+ slider.vertical !== slider.reverse,
45
+ ),
46
+ )
47
+ return () => {
48
+ const orientation = slider.vertical ? 'vertical' : 'horizontal'
49
+ return h(
50
+ 'div',
51
+ {
52
+ 'data-orientation': orientation,
53
+ style: {
54
+ '--thickness': cssLength(props.thickness),
55
+ '--length': cssLength(props.length),
56
+ '--gap': cssLength(props.gap),
57
+ position: 'absolute',
58
+ translate: slider.vertical
59
+ ? 'var(--translate, 0 -50%)'
60
+ : 'var(--translate, -50% 0)',
61
+ zIndex: 10,
62
+ left: slider.vertical ? undefined : `${percent.value}%`,
63
+ top: slider.vertical ? `${percent.value}%` : undefined,
64
+ },
65
+ },
66
+ [
67
+ props.mark
68
+ ? h('div', {
69
+ class: props.classes?.mark,
70
+ style: props.styles?.mark,
71
+ 'data-orientation': orientation,
72
+ })
73
+ : null,
74
+ props.label !== null
75
+ ? h(
76
+ 'div',
77
+ { class: props.classes?.label, style: props.styles?.label },
78
+ String(props.label ?? props.value),
79
+ )
80
+ : null,
81
+ ],
82
+ )
83
+ }
84
+ },
85
+ })
@@ -0,0 +1,92 @@
1
+ import { defineComponent, h, onBeforeUnmount, ref, watchEffect } from 'vue'
2
+
3
+ import { checkPlacement } from '../_util/placement'
4
+ import { visuallyHiddenRangeInput } from '../_util/visually-hidden-range-input'
5
+
6
+ import { useSliderContext } from './context'
7
+
8
+ /**
9
+ * The thumb, placed at the value. The ARIA (`aria-label` and friends) lands
10
+ * on the range input inside it, which is the control and takes the focus.
11
+ */
12
+ export const SliderThumb = /* @__PURE__ */ defineComponent({
13
+ name: 'SliderThumb',
14
+ inheritAttrs: false,
15
+ props: {
16
+ /** Sets `--color`, for the theme to colour the thumb with. */
17
+ color: String,
18
+ },
19
+ setup(props, { slots, attrs, expose }) {
20
+ const slider = useSliderContext()
21
+ checkPlacement('SliderThumb', 'SliderTrack')
22
+ const input = ref<HTMLInputElement | null>(null)
23
+ watchEffect(() => slider.setThumb(input.value))
24
+ onBeforeUnmount(() => slider.setThumb(null))
25
+
26
+ expose({
27
+ focus: () => {
28
+ if (!slider.disabled) input.value?.focus()
29
+ },
30
+ blur: () => input.value?.blur(),
31
+ })
32
+
33
+ return () => {
34
+ const {
35
+ 'aria-label': ariaLabel,
36
+ 'aria-labelledby': ariaLabelledby,
37
+ 'aria-describedby': ariaDescribedby,
38
+ 'aria-valuetext': ariaValuetext,
39
+ style,
40
+ ...rest
41
+ } = attrs
42
+ return h(
43
+ 'div',
44
+ {
45
+ 'data-disabled': slider.disabled ? '' : undefined,
46
+ 'data-readonly': slider.readonly ? '' : undefined,
47
+ ...rest,
48
+ // Placed by a percentage of the track, measured from its own
49
+ // centre; where it sits comes after the caller's style.
50
+ style: [
51
+ {
52
+ position: 'absolute',
53
+ translate: 'var(--translate, -50% -50%)',
54
+ zIndex: 100,
55
+ '--color': props.color,
56
+ },
57
+ style,
58
+ {
59
+ top: slider.vertical ? `${slider.percent}%` : '50%',
60
+ left: slider.vertical ? '50%' : `${slider.percent}%`,
61
+ },
62
+ ],
63
+ },
64
+ [
65
+ visuallyHiddenRangeInput({
66
+ ref: input,
67
+ value: slider.value,
68
+ min: slider.min,
69
+ max: slider.max,
70
+ step: slider.step,
71
+ disabled: slider.disabled,
72
+ 'aria-readonly': slider.readonly,
73
+ 'aria-orientation': slider.vertical ? 'vertical' : 'horizontal',
74
+ 'aria-label': ariaLabel,
75
+ 'aria-labelledby': ariaLabelledby,
76
+ 'aria-describedby': ariaDescribedby,
77
+ 'aria-valuetext': ariaValuetext,
78
+ onInput: (event: Event) => {
79
+ const target = event.target as HTMLInputElement
80
+ if (slider.readonly) {
81
+ target.value = String(slider.value)
82
+ return
83
+ }
84
+ slider.change(target.valueAsNumber)
85
+ },
86
+ }),
87
+ slots.default?.(),
88
+ ],
89
+ )
90
+ }
91
+ },
92
+ })
@@ -0,0 +1,50 @@
1
+ import { defineComponent, h, onBeforeUnmount, ref, watchEffect } from 'vue'
2
+
3
+ import { cssLength } from '@tremolo-ui/dom'
4
+
5
+ import { providePlacement } from '../_util/placement'
6
+
7
+ import { useSliderContext } from './context'
8
+
9
+ /** The track the thumb runs along, and what the pointer is measured against. */
10
+ export const SliderTrack = /* @__PURE__ */ defineComponent({
11
+ name: 'SliderTrack',
12
+ props: {
13
+ /** How long the track is along the axis the slider runs. Sets `--length`. */
14
+ length: [Number, String],
15
+ /** How thick the track is across that axis. Sets `--thickness`. */
16
+ thickness: [Number, String],
17
+ /** Colour of the part from `min` to the value. Sets `--active`. */
18
+ active: String,
19
+ /** Colour of the rest of the track. Sets `--inactive`. */
20
+ inactive: String,
21
+ },
22
+ setup(props, { slots }) {
23
+ const slider = useSliderContext()
24
+ providePlacement('SliderTrack')
25
+ const el = ref<HTMLDivElement | null>(null)
26
+ watchEffect(() => slider.setTrack(el.value))
27
+ onBeforeUnmount(() => slider.setTrack(null))
28
+ // `--percent` is where the value sits, for the theme to paint the fill
29
+ // with: the one number CSS cannot work out on its own.
30
+ return () =>
31
+ h(
32
+ 'div',
33
+ {
34
+ ref: el,
35
+ 'data-disabled': slider.disabled ? '' : undefined,
36
+ 'data-orientation': slider.vertical ? 'vertical' : 'horizontal',
37
+ 'data-flipped': slider.vertical !== slider.reverse ? '' : undefined,
38
+ style: {
39
+ '--active': props.active,
40
+ '--inactive': props.inactive,
41
+ '--length': cssLength(props.length),
42
+ '--thickness': cssLength(props.thickness),
43
+ '--percent': `${slider.percent}%`,
44
+ position: 'relative',
45
+ },
46
+ },
47
+ slots.default?.(),
48
+ )
49
+ },
50
+ })
@@ -0,0 +1,33 @@
1
+ import { type InjectionKey } from 'vue'
2
+
3
+ import { type Scale } from '@tremolo-ui/functions'
4
+
5
+ import { injectContext } from '../_util/context'
6
+
7
+ /** What `Slider` shares with its parts. The fields are getters. */
8
+ export interface SliderContextValue {
9
+ readonly value: number
10
+ readonly min: number
11
+ readonly max: number
12
+ readonly step: number
13
+ readonly scale: Scale
14
+ readonly vertical: boolean
15
+ readonly reverse: boolean
16
+ readonly disabled: boolean
17
+ readonly readonly: boolean
18
+ /** Where the value sits along the track, as a percentage from the top or left. */
19
+ readonly percent: number
20
+ /** Set the value, as an input on the slider would. */
21
+ change: (value: number) => void
22
+ /** @internal `SliderTrack` registers the element the pointer is measured against. */
23
+ setTrack: (element: HTMLElement | null) => void
24
+ /** @internal `SliderThumb` registers the input the focus goes to. */
25
+ setThumb: (input: HTMLInputElement | null) => void
26
+ }
27
+
28
+ export const SliderKey: InjectionKey<SliderContextValue> = Symbol('Slider')
29
+
30
+ /** The context of the enclosing `Slider`, for a part of your own. */
31
+ export function useSliderContext(): SliderContextValue {
32
+ return injectContext(SliderKey, 'Slider')
33
+ }
@@ -0,0 +1,6 @@
1
+ export { useSliderContext, type SliderContextValue } from './context'
2
+ export { Slider } from './Slider'
3
+ export { SliderMarks } from './SliderMarks'
4
+ export { SliderMarksOption } from './SliderMarksOption'
5
+ export { SliderThumb } from './SliderThumb'
6
+ export { SliderTrack } from './SliderTrack'
@@ -0,0 +1,250 @@
1
+ import { computed, defineComponent, h, provide, ref, type PropType } from 'vue'
2
+
3
+ import {
4
+ applyDelta,
5
+ arrowKeyMove,
6
+ elementMapping,
7
+ selectModifier,
8
+ toXY,
9
+ valuePercent,
10
+ wheelMove,
11
+ type AxisMove,
12
+ type AxisOptions,
13
+ type InputEventOption,
14
+ type ModifierState,
15
+ type ModifierValue,
16
+ type XY,
17
+ type XYInput,
18
+ } from '@tremolo-ui/dom'
19
+ import { linearScale, type Scale } from '@tremolo-ui/functions'
20
+
21
+ import { useDragValue } from '../../composables/useDragValue'
22
+ import { useWheel } from '../../composables/useWheel'
23
+ import { dragSensitivityProp, keyboardProp, wheelProp } from '../_util/props'
24
+ import { useCheckSteps } from '../_util/useCheckSteps'
25
+
26
+ import { XYPadKey } from './context'
27
+
28
+ /**
29
+ * Two-dimensional slider. The per-axis settings are `[x, y]` tuples, and a
30
+ * plain value applies to both axes. Bind the value with `v-model`.
31
+ */
32
+ export const XYPad = /* @__PURE__ */ defineComponent({
33
+ name: 'XYPad',
34
+ props: {
35
+ /** The current value as `[x, y]`. Bind it with `v-model`. */
36
+ modelValue: {
37
+ type: Array as unknown as PropType<XY<number>>,
38
+ required: true,
39
+ },
40
+ /** The value at the start of the travel, per axis. */
41
+ min: {
42
+ type: [Number, Array] as PropType<XYInput<number>>,
43
+ required: true,
44
+ },
45
+ /** The value at the end of the travel, per axis. */
46
+ max: {
47
+ type: [Number, Array] as PropType<XYInput<number>>,
48
+ required: true,
49
+ },
50
+ /** Granularity of the value, per axis. @default 1 */
51
+ step: { type: [Number, Array] as PropType<XYInput<number>>, default: 1 },
52
+ /** How the value is distributed, per axis. @default linearScale */
53
+ scale: {
54
+ type: [Object, Array] as PropType<XYInput<Scale>>,
55
+ default: () => linearScale,
56
+ },
57
+ /** Grow the value the other way, per axis. `y` grows downwards unless reversed. */
58
+ reverse: {
59
+ type: [Boolean, Array] as PropType<XYInput<boolean>>,
60
+ default: false,
61
+ },
62
+ /** The cursor to show while dragging. @default { cursor: 'pointer' } */
63
+ externalStyles: Object as PropType<{ cursor?: string }>,
64
+ wheel: wheelProp,
65
+ keyboard: keyboardProp,
66
+ dragSensitivity: dragSensitivityProp,
67
+ /** Make the pad unchangeable and remove it from the tab order. */
68
+ disabled: Boolean,
69
+ /** Make the value unchangeable. */
70
+ readonly: Boolean,
71
+ },
72
+ emits: {
73
+ 'update:modelValue': (value: XY<number>) => Array.isArray(value),
74
+ dragStart: (value: XY<number>) => Array.isArray(value),
75
+ dragEnd: (value: XY<number>) => Array.isArray(value),
76
+ },
77
+ setup(props, { slots, emit, expose }) {
78
+ const root = ref<HTMLDivElement | null>(null)
79
+ let area: HTMLElement | null = null
80
+ let thumb: HTMLInputElement | null = null
81
+
82
+ const inactive = computed(() => props.disabled || props.readonly)
83
+ const min = computed(() => toXY(props.min))
84
+ const max = computed(() => toXY(props.max))
85
+ const step = computed(() => toXY(props.step))
86
+ const scale = computed(() => toXY(props.scale))
87
+ const reverse = computed(() => toXY(props.reverse))
88
+ const percent = computed(
89
+ () =>
90
+ [0, 1].map((i) =>
91
+ valuePercent(
92
+ props.modelValue[i],
93
+ { min: min.value[i], max: max.value[i], scale: scale.value[i] },
94
+ reverse.value[i],
95
+ ),
96
+ ) as XY<number>,
97
+ )
98
+ // `reverse` only concerns the drag, where positions follow the screen; the
99
+ // key and wheel handlers flip the direction themselves.
100
+ const axis = computed(
101
+ () =>
102
+ [0, 1].map((i) => ({
103
+ min: min.value[i],
104
+ max: max.value[i],
105
+ step: step.value[i],
106
+ scale: scale.value[i],
107
+ reverse: reverse.value[i],
108
+ })) as XY<AxisOptions>,
109
+ )
110
+
111
+ for (const [i, name] of [
112
+ [0, 'x'],
113
+ [1, 'y'],
114
+ ] as const) {
115
+ useCheckSteps(() => ({
116
+ component: 'XYPad',
117
+ axis: name,
118
+ range: axis.value[i],
119
+ keyboard: props.keyboard,
120
+ wheel: props.wheel,
121
+ }))
122
+ }
123
+
124
+ const change = (next: XY<number>) => emit('update:modelValue', next)
125
+
126
+ /** Move one axis by one press of `option`, in screen coordinates. */
127
+ function nudge(
128
+ { axis: i, direction }: AxisMove,
129
+ option: ModifierValue<InputEventOption>,
130
+ modifiers: ModifierState,
131
+ ) {
132
+ const value = props.modelValue
133
+ const next = applyDelta(
134
+ value[i],
135
+ reverse.value[i] ? -direction : direction,
136
+ option,
137
+ axis.value[i],
138
+ modifiers,
139
+ )
140
+ change(i === 0 ? [next, value[1]] : [value[0], next])
141
+ }
142
+
143
+ provide(XYPadKey, {
144
+ get value() {
145
+ return props.modelValue
146
+ },
147
+ get min() {
148
+ return min.value
149
+ },
150
+ get max() {
151
+ return max.value
152
+ },
153
+ get step() {
154
+ return step.value
155
+ },
156
+ get scale() {
157
+ return scale.value
158
+ },
159
+ get reverse() {
160
+ return reverse.value
161
+ },
162
+ get disabled() {
163
+ return props.disabled
164
+ },
165
+ get readonly() {
166
+ return props.readonly
167
+ },
168
+ get percent() {
169
+ return percent.value
170
+ },
171
+ change,
172
+ setArea: (element) => {
173
+ area = element
174
+ },
175
+ setThumb: (input) => {
176
+ thumb = input
177
+ },
178
+ })
179
+
180
+ useDragValue(root, () => ({
181
+ axis: axis.value,
182
+ mapping: elementMapping(() => area, {
183
+ sensitivity: (state) =>
184
+ selectModifier(props.dragSensitivity, state.event).value,
185
+ }),
186
+ updateOnPointerDown: true,
187
+ cursor: inactive.value
188
+ ? undefined
189
+ : (props.externalStyles?.cursor ?? 'pointer'),
190
+ shouldStart: () => !inactive.value,
191
+ onChange: (v) => {
192
+ if (!inactive.value) change(v)
193
+ },
194
+ onDragStart: (v) => {
195
+ if (inactive.value) return
196
+ thumb?.focus()
197
+ emit('dragStart', v)
198
+ },
199
+ onDragEnd: (v) => {
200
+ if (!inactive.value) emit('dragEnd', v)
201
+ },
202
+ }))
203
+
204
+ useWheel(
205
+ root,
206
+ (event) => {
207
+ if (!props.wheel || inactive.value) return
208
+ const move = wheelMove(event)
209
+ if (!move) return
210
+ event.preventDefault()
211
+ nudge(move, props.wheel, event)
212
+ },
213
+ { requireFocus: true },
214
+ )
215
+
216
+ expose({
217
+ focus: () => {
218
+ if (!props.disabled) thumb?.focus()
219
+ },
220
+ blur: () => thumb?.blur(),
221
+ })
222
+
223
+ return () =>
224
+ h(
225
+ 'div',
226
+ {
227
+ ref: root,
228
+ role: 'group',
229
+ tabindex: -1,
230
+ 'data-disabled': props.disabled ? '' : undefined,
231
+ 'data-readonly': props.readonly ? '' : undefined,
232
+ onKeydown: (event: KeyboardEvent) => {
233
+ // The key picks the axis, whichever of the two inputs holds the
234
+ // focus: the focus lands on the x input.
235
+ const move = arrowKeyMove(event.key)
236
+ if (!move) return
237
+ event.preventDefault()
238
+ if (props.keyboard && !inactive.value) {
239
+ nudge(move, props.keyboard, event)
240
+ }
241
+ },
242
+ onFocus: () => {
243
+ if (!props.disabled) thumb?.focus()
244
+ },
245
+ onBlur: () => thumb?.blur(),
246
+ },
247
+ slots.default?.(),
248
+ )
249
+ },
250
+ })
@@ -0,0 +1,20 @@
1
+ import { defineComponent, h, onBeforeUnmount, ref, watchEffect } from 'vue'
2
+
3
+ import { providePlacement } from '../_util/placement'
4
+
5
+ import { useXYPadContext } from './context'
6
+
7
+ /** The area the thumb moves in, and what the pointer is measured against. */
8
+ export const XYPadArea = /* @__PURE__ */ defineComponent({
9
+ name: 'XYPadArea',
10
+ setup(_, { slots }) {
11
+ const pad = useXYPadContext()
12
+ providePlacement('XYPadArea')
13
+ const el = ref<HTMLDivElement | null>(null)
14
+ watchEffect(() => pad.setArea(el.value))
15
+ onBeforeUnmount(() => pad.setArea(null))
16
+ // The thumb inside is placed against this box.
17
+ return () =>
18
+ h('div', { ref: el, style: { position: 'relative' } }, slots.default?.())
19
+ },
20
+ })
@@ -0,0 +1,105 @@
1
+ import {
2
+ defineComponent,
3
+ h,
4
+ onBeforeUnmount,
5
+ ref,
6
+ watchEffect,
7
+ type PropType,
8
+ } from 'vue'
9
+
10
+ import { toXY, type XYInput } from '@tremolo-ui/dom'
11
+
12
+ import { checkPlacement } from '../_util/placement'
13
+ import { visuallyHiddenRangeInput } from '../_util/visually-hidden-range-input'
14
+
15
+ import { useXYPadContext } from './context'
16
+
17
+ type PerAxis = XYInput<string | undefined>
18
+
19
+ /** The thumb, placed at the value, with one range input per axis inside. */
20
+ export const XYPadThumb = /* @__PURE__ */ defineComponent({
21
+ name: 'XYPadThumb',
22
+ inheritAttrs: false,
23
+ props: {
24
+ /** Sets `--color`, for the theme to colour the thumb with. */
25
+ color: String,
26
+ /** The accessible name of each axis, as `[x, y]`. */
27
+ ariaLabel: [String, Array] as PropType<PerAxis>,
28
+ /** The ids of what labels each axis, as `[x, y]` or one for both. */
29
+ ariaLabelledby: [String, Array] as PropType<PerAxis>,
30
+ /** The ids of what describes each axis, as `[x, y]` or one for both. */
31
+ ariaDescribedby: [String, Array] as PropType<PerAxis>,
32
+ /** What the value of each axis means, as `[x, y]` or one for both. */
33
+ ariaValuetext: [String, Array] as PropType<PerAxis>,
34
+ },
35
+ setup(props, { slots, attrs, expose }) {
36
+ const pad = useXYPadContext()
37
+ checkPlacement('XYPadThumb', 'XYPadArea')
38
+ const x = ref<HTMLInputElement | null>(null)
39
+ const y = ref<HTMLInputElement | null>(null)
40
+ watchEffect(() => pad.setThumb(x.value))
41
+ onBeforeUnmount(() => pad.setThumb(null))
42
+
43
+ expose({
44
+ focus: () => {
45
+ if (!pad.disabled) x.value?.focus()
46
+ },
47
+ blur: () => {
48
+ x.value?.blur()
49
+ y.value?.blur()
50
+ },
51
+ })
52
+
53
+ function input(axis: 0 | 1) {
54
+ const labels = toXY(props.ariaLabel)
55
+ return visuallyHiddenRangeInput({
56
+ ref: axis === 0 ? x : y,
57
+ 'data-axis': axis === 0 ? 'x' : 'y',
58
+ value: pad.value[axis],
59
+ min: pad.min[axis],
60
+ max: pad.max[axis],
61
+ step: pad.step[axis],
62
+ disabled: pad.disabled,
63
+ 'aria-readonly': pad.readonly,
64
+ 'aria-orientation': axis === 0 ? 'horizontal' : 'vertical',
65
+ 'aria-label': labels[axis] ?? (axis === 0 ? 'x' : 'y'),
66
+ 'aria-labelledby': toXY(props.ariaLabelledby)[axis],
67
+ 'aria-describedby': toXY(props.ariaDescribedby)[axis],
68
+ 'aria-valuetext': toXY(props.ariaValuetext)[axis],
69
+ onInput: (event: Event) => {
70
+ const target = event.target as HTMLInputElement
71
+ if (pad.readonly) {
72
+ target.value = String(pad.value[axis])
73
+ return
74
+ }
75
+ const next = [...pad.value] as [number, number]
76
+ next[axis] = target.valueAsNumber
77
+ pad.change(next)
78
+ },
79
+ })
80
+ }
81
+
82
+ return () => {
83
+ const { style, ...rest } = attrs
84
+ return h(
85
+ 'div',
86
+ {
87
+ 'data-disabled': pad.disabled ? '' : undefined,
88
+ 'data-readonly': pad.readonly ? '' : undefined,
89
+ ...rest,
90
+ style: [
91
+ {
92
+ position: 'absolute',
93
+ translate: 'var(--translate, -50% -50%)',
94
+ zIndex: 100,
95
+ '--color': props.color,
96
+ },
97
+ style,
98
+ { left: `${pad.percent[0]}%`, top: `${pad.percent[1]}%` },
99
+ ],
100
+ },
101
+ [input(0), input(1), slots.default?.()],
102
+ )
103
+ }
104
+ },
105
+ })
@@ -0,0 +1,33 @@
1
+ import { type InjectionKey } from 'vue'
2
+
3
+ import { type XY } from '@tremolo-ui/dom'
4
+ import { type Scale } from '@tremolo-ui/functions'
5
+
6
+ import { injectContext } from '../_util/context'
7
+
8
+ /** What `XYPad` shares with its parts. The fields are getters. */
9
+ export interface XYPadContextValue {
10
+ readonly value: XY<number>
11
+ readonly min: XY<number>
12
+ readonly max: XY<number>
13
+ readonly step: XY<number>
14
+ readonly scale: XY<Scale>
15
+ readonly reverse: XY<boolean>
16
+ readonly disabled: boolean
17
+ readonly readonly: boolean
18
+ /** Where the value sits in the area, as percentages from the left and top. */
19
+ readonly percent: XY<number>
20
+ /** Set the value, as an input on the pad would. */
21
+ change: (value: XY<number>) => void
22
+ /** @internal `XYPadArea` registers the element the pointer is measured against. */
23
+ setArea: (element: HTMLElement | null) => void
24
+ /** @internal `XYPadThumb` registers the input the focus goes to. */
25
+ setThumb: (input: HTMLInputElement | null) => void
26
+ }
27
+
28
+ export const XYPadKey: InjectionKey<XYPadContextValue> = Symbol('XYPad')
29
+
30
+ /** The context of the enclosing `XYPad`, for a part of your own. */
31
+ export function useXYPadContext(): XYPadContextValue {
32
+ return injectContext(XYPadKey, 'XYPad')
33
+ }
@@ -0,0 +1,4 @@
1
+ export { useXYPadContext, type XYPadContextValue } from './context'
2
+ export { XYPad } from './XYPad'
3
+ export { XYPadArea } from './XYPadArea'
4
+ export { XYPadThumb } from './XYPadThumb'
@@ -0,0 +1,8 @@
1
+ import { inject, type InjectionKey } from 'vue'
2
+
3
+ /** Read a component's context, or explain which parent is missing. */
4
+ export function injectContext<T>(key: InjectionKey<T>, parent: string): T {
5
+ const context = inject(key, null)
6
+ if (!context) throw new Error(`Missing ${parent} in the tree`)
7
+ return context
8
+ }