@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,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,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
|
+
}
|