@bluerobotics/bluevue 0.1.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 +113 -0
- package/dist/bluevue.js +1176 -0
- package/dist/components/BlueButtonGroup.vue.d.ts +101 -0
- package/dist/components/BlueExpansiblePanel.vue.d.ts +38 -0
- package/dist/components/BlueInput.vue.d.ts +70 -0
- package/dist/components/BlueLoadingDialog.vue.d.ts +27 -0
- package/dist/components/BlueMenu.vue.d.ts +57 -0
- package/dist/components/BluePromptDialog.vue.d.ts +37 -0
- package/dist/components/BlueSelect.vue.d.ts +76 -0
- package/dist/components/BlueSlider.vue.d.ts +51 -0
- package/dist/components/BlueSwitch.vue.d.ts +39 -0
- package/dist/composables/useBluePopover.d.ts +38 -0
- package/dist/index.d.ts +10 -0
- package/dist/style.css +2 -0
- package/package.json +64 -0
- package/src/assets/br-logo-white.svg +1 -0
- package/src/components/BlueButtonGroup.vue +382 -0
- package/src/components/BlueExpansiblePanel.vue +72 -0
- package/src/components/BlueInput.vue +247 -0
- package/src/components/BlueLoadingDialog.vue +76 -0
- package/src/components/BlueMenu.vue +119 -0
- package/src/components/BluePromptDialog.vue +151 -0
- package/src/components/BlueSelect.vue +236 -0
- package/src/components/BlueSlider.vue +464 -0
- package/src/components/BlueSwitch.vue +117 -0
- package/src/composables/useBluePopover.ts +153 -0
- package/src/index.ts +11 -0
- package/src/styles/bluevue.css +124 -0
- package/src/styles/bundled.css +9 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex w-full justify-between items-center">
|
|
3
|
+
<!-- shrink-0 keeps the label at its natural width: letting flex shrink it by the fraction
|
|
4
|
+
of a pixel that rounding introduces is enough for Chromium to ellipsize a label that
|
|
5
|
+
fits. The cap is what makes an outsized label ellipsize instead of eating the row. -->
|
|
6
|
+
<div
|
|
7
|
+
v-if="label"
|
|
8
|
+
class="min-w-0 max-w-[45%] shrink-0"
|
|
9
|
+
>
|
|
10
|
+
<label
|
|
11
|
+
class="block truncate text-start mr-6"
|
|
12
|
+
:title="label"
|
|
13
|
+
:class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
|
|
14
|
+
>
|
|
15
|
+
{{ label }}
|
|
16
|
+
</label>
|
|
17
|
+
</div>
|
|
18
|
+
<div v-else />
|
|
19
|
+
<slot name="insetElement" />
|
|
20
|
+
|
|
21
|
+
<div
|
|
22
|
+
v-if="infoTooltip"
|
|
23
|
+
class="relative group inline-flex items-center shrink-0 mr-2 ml-auto"
|
|
24
|
+
>
|
|
25
|
+
<span
|
|
26
|
+
class="mdi mdi-information-outline text-[16px] opacity-60 cursor-help"
|
|
27
|
+
:class="theme === 'dark' ? 'text-white' : 'text-black'"
|
|
28
|
+
/>
|
|
29
|
+
<div
|
|
30
|
+
class="absolute bottom-full mb-1 right-0 hidden group-hover:block w-max max-w-[280px] px-2 py-1 text-xs rounded bg-gray-700 text-white z-[1000]"
|
|
31
|
+
>
|
|
32
|
+
{{ infoTooltip }}
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="flex flex-col items-end min-w-0 shrink-[1000]">
|
|
37
|
+
<button
|
|
38
|
+
ref="anchorRef"
|
|
39
|
+
v-bind="activatorProps"
|
|
40
|
+
type="button"
|
|
41
|
+
:disabled="disabled"
|
|
42
|
+
class="relative inline-flex items-center justify-between pl-4 rounded-[6px] bluevue-elevation-1 min-w-[90px] max-w-full"
|
|
43
|
+
:class="[
|
|
44
|
+
disabled ? 'opacity-30' : 'cursor-pointer',
|
|
45
|
+
hasError ? 'border-2 border-solid border-[var(--bluevue-error)]' : ''
|
|
46
|
+
]"
|
|
47
|
+
:style="{ height: height || '30px', width: width || 'auto', backgroundColor: closedBackground }"
|
|
48
|
+
>
|
|
49
|
+
<span
|
|
50
|
+
class="text-sm font-medium truncate min-w-0 mr-1 -mb-[1px]"
|
|
51
|
+
:class="selectedTextClass"
|
|
52
|
+
>
|
|
53
|
+
{{ !multiSelect ? selectedItem.name : selectedValues.length > 0 ? selectedValues.join(', ') : 'Select...' }}
|
|
54
|
+
</span>
|
|
55
|
+
<span
|
|
56
|
+
class="mdi mdi-menu-down text-[24px] leading-none shrink-0 transition-transform"
|
|
57
|
+
:class="[iconClass, isOpen ? 'rotate-180' : '']"
|
|
58
|
+
/>
|
|
59
|
+
</button>
|
|
60
|
+
<div
|
|
61
|
+
v-if="hasError"
|
|
62
|
+
class="text-[14px] text-[var(--bluevue-error)]"
|
|
63
|
+
>
|
|
64
|
+
{{ (errorMessages?.[0]) || '' }}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div
|
|
69
|
+
:id="popoverId"
|
|
70
|
+
ref="popoverRef"
|
|
71
|
+
popover
|
|
72
|
+
class="bluevue-popover"
|
|
73
|
+
:style="floatingStyles"
|
|
74
|
+
@toggle="onToggle"
|
|
75
|
+
>
|
|
76
|
+
<ul
|
|
77
|
+
class="bluevue-elevation-5 max-h-[60vh] overflow-y-auto rounded-[4px] border border-[#FFFFFF22]"
|
|
78
|
+
:class="[
|
|
79
|
+
theme === 'dark' ? 'bg-[var(--bluevue-surface)]' : 'bg-white',
|
|
80
|
+
isPositioned ? 'opacity-100' : 'opacity-0',
|
|
81
|
+
]"
|
|
82
|
+
>
|
|
83
|
+
<li
|
|
84
|
+
v-for="(opt, idx) in items"
|
|
85
|
+
:key="opt.name"
|
|
86
|
+
>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
:disabled="disabled || opt.disabled"
|
|
90
|
+
class="flex w-full items-center justify-between gap-2 whitespace-nowrap px-4 py-2 text-left text-sm"
|
|
91
|
+
:class="[
|
|
92
|
+
itemTextClass(opt),
|
|
93
|
+
disabled || opt.disabled
|
|
94
|
+
? 'cursor-not-allowed'
|
|
95
|
+
: theme === 'dark' ? 'cursor-pointer hover:bg-[#ffffff11]' : 'cursor-pointer hover:bg-gray-100',
|
|
96
|
+
]"
|
|
97
|
+
@click="selectOption(idx)"
|
|
98
|
+
>
|
|
99
|
+
<span
|
|
100
|
+
class="mdi mdi-check text-[16px] leading-none"
|
|
101
|
+
:class="selectedValues.includes(opt.value || opt.name) ? '' : 'invisible'"
|
|
102
|
+
/>
|
|
103
|
+
{{ opt.name }}
|
|
104
|
+
</button>
|
|
105
|
+
</li>
|
|
106
|
+
</ul>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</template>
|
|
110
|
+
|
|
111
|
+
<script setup lang="ts">
|
|
112
|
+
import { computed, ref, watch } from 'vue'
|
|
113
|
+
|
|
114
|
+
import { useBluePopover } from '../composables/useBluePopover'
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Option in the select
|
|
118
|
+
*/
|
|
119
|
+
interface OptionItem {
|
|
120
|
+
/**
|
|
121
|
+
* Name of the option to display
|
|
122
|
+
*/
|
|
123
|
+
name: string
|
|
124
|
+
/**
|
|
125
|
+
* Value of the option, if not provided, name will be used
|
|
126
|
+
*/
|
|
127
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
128
|
+
value?: any
|
|
129
|
+
/**
|
|
130
|
+
* Whether the option is disabled
|
|
131
|
+
*/
|
|
132
|
+
disabled?: boolean
|
|
133
|
+
/**
|
|
134
|
+
* Callback when the option is selected
|
|
135
|
+
*/
|
|
136
|
+
onSelected?: () => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const props = defineProps<{
|
|
140
|
+
/** Options to select from */
|
|
141
|
+
items: OptionItem[]
|
|
142
|
+
/** Disable entire select */
|
|
143
|
+
disabled?: boolean
|
|
144
|
+
/** light or dark theme */
|
|
145
|
+
theme?: 'light' | 'dark'
|
|
146
|
+
/** Label on the left */
|
|
147
|
+
label?: string
|
|
148
|
+
/** Optional info tooltip shown via an info icon next to the control. */
|
|
149
|
+
infoTooltip?: string
|
|
150
|
+
/** Control height */
|
|
151
|
+
height?: string
|
|
152
|
+
/** Control width */
|
|
153
|
+
width?: string
|
|
154
|
+
/** Multiple values selection */
|
|
155
|
+
multiSelect?: boolean
|
|
156
|
+
/** Model value for v-model */
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
modelValue?: any
|
|
159
|
+
/** Error messages, used to give feedback from validators */
|
|
160
|
+
errorMessages?: string[]
|
|
161
|
+
}>()
|
|
162
|
+
|
|
163
|
+
const emit = defineEmits<{
|
|
164
|
+
(e: 'update:modelValue', value: string | number | (string | number)[]): void
|
|
165
|
+
}>()
|
|
166
|
+
|
|
167
|
+
const selectedValues = ref<(string | number)[]>([])
|
|
168
|
+
const selectedItem = ref<OptionItem>({ name: 'Select...' })
|
|
169
|
+
|
|
170
|
+
const {
|
|
171
|
+
anchorRef,
|
|
172
|
+
popoverRef,
|
|
173
|
+
popoverId,
|
|
174
|
+
activatorProps,
|
|
175
|
+
isOpen,
|
|
176
|
+
isPositioned,
|
|
177
|
+
floatingStyles,
|
|
178
|
+
hide,
|
|
179
|
+
onToggle,
|
|
180
|
+
} = useBluePopover({ matchAnchorWidth: true })
|
|
181
|
+
|
|
182
|
+
const hasError = computed(() =>
|
|
183
|
+
props.errorMessages && props.errorMessages.length > 0
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Applied inline because the activator is a <button>, and a host reset that is unlayered, as
|
|
187
|
+
// Vuetify's is, sets button { background-color: transparent } and beats any Tailwind utility
|
|
188
|
+
// for it: utilities sit in a cascade layer, and unlayered rules win over layered ones outright.
|
|
189
|
+
const closedBackground = computed(() => (props.theme === 'dark' ? '#FFFFFF05' : '#00000011'))
|
|
190
|
+
|
|
191
|
+
const selectOption = (index: number) => {
|
|
192
|
+
if (props.multiSelect) {
|
|
193
|
+
const value = props.items[index].value || props.items[index].name
|
|
194
|
+
const idx = selectedValues.value.indexOf(value)
|
|
195
|
+
if (idx > -1) {
|
|
196
|
+
selectedValues.value.splice(idx, 1)
|
|
197
|
+
} else {
|
|
198
|
+
selectedValues.value.push(value)
|
|
199
|
+
}
|
|
200
|
+
emit('update:modelValue', selectedValues.value)
|
|
201
|
+
} else {
|
|
202
|
+
const value = props.items[index].value || props.items[index].name
|
|
203
|
+
selectedItem.value = props.items[index]
|
|
204
|
+
emit('update:modelValue', value)
|
|
205
|
+
// Picking one of several closes the list; ticking one of many leaves it up for the next tick.
|
|
206
|
+
hide()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const iconClass = computed(() => (props.theme === 'dark' ? 'text-white' : 'text-black'))
|
|
211
|
+
const selectedTextClass = computed(() => (props.theme === 'dark' ? 'text-white' : 'text-black'))
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Returns the class for the item text based on the option and props
|
|
215
|
+
* @param opt
|
|
216
|
+
*/
|
|
217
|
+
function itemTextClass(opt: OptionItem) {
|
|
218
|
+
if (opt.disabled || props.disabled) return 'text-gray-500'
|
|
219
|
+
return props.theme === 'dark' ? 'text-white' : 'text-black'
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
watch(
|
|
223
|
+
() => props.modelValue,
|
|
224
|
+
(newVal) => {
|
|
225
|
+
if (!props.multiSelect) {
|
|
226
|
+
const found = props.items.find(item =>
|
|
227
|
+
item.value !== undefined ? item.value === newVal : item.name === newVal
|
|
228
|
+
)
|
|
229
|
+
selectedItem.value = found ?? { name: 'Select...' }
|
|
230
|
+
} else if (Array.isArray(newVal)) {
|
|
231
|
+
selectedValues.value = newVal
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
{ immediate: true }
|
|
235
|
+
)
|
|
236
|
+
</script>
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex w-full justify-between items-center">
|
|
3
|
+
<!-- shrink-0 keeps the label at its natural width: letting flex shrink it by the fraction
|
|
4
|
+
of a pixel that rounding introduces is enough for Chromium to ellipsize a label that
|
|
5
|
+
fits. The cap is what makes an outsized label ellipsize instead of eating the row. -->
|
|
6
|
+
<div
|
|
7
|
+
v-if="label"
|
|
8
|
+
class="min-w-0 max-w-[45%] shrink-0"
|
|
9
|
+
>
|
|
10
|
+
<label
|
|
11
|
+
class="block truncate text-start mr-6"
|
|
12
|
+
:title="label"
|
|
13
|
+
:class="[theme === 'dark' ? 'text-white' : 'text-black', disabled ? 'opacity-30' : '']"
|
|
14
|
+
>{{ label }}</label>
|
|
15
|
+
</div>
|
|
16
|
+
<!-- Shrinks far more eagerly than the label, so a tight row narrows the track first and
|
|
17
|
+
only ellipsizes the label once the track is down to its floor. -->
|
|
18
|
+
<div class="flex justify-between items-center min-w-0 shrink-[1000]">
|
|
19
|
+
<div
|
|
20
|
+
name="slider-track"
|
|
21
|
+
class="relative overflow-visible rounded-[6px] bluevue-elevation-1 min-w-[140px] max-w-full"
|
|
22
|
+
:class="[theme === 'dark' ? 'bg-[#464646AA]' : 'bg-[#00000011]', disabled ? 'opacity-30' : '']"
|
|
23
|
+
:style="{ width: width || '100%', height: height || '30px', cursor: disabled ? 'not-allowed' : 'pointer' }"
|
|
24
|
+
>
|
|
25
|
+
<div class="absolute inset-x-[18%] top-1/2 -translate-y-1/2 flex justify-between pointer-events-none">
|
|
26
|
+
<div
|
|
27
|
+
v-for="i in 6"
|
|
28
|
+
:key="i"
|
|
29
|
+
class="mdi mdi-circle w-2 h-2"
|
|
30
|
+
:style="{
|
|
31
|
+
fontSize: '6px',
|
|
32
|
+
color: theme === 'dark' ? '#ffffff11' : '#00000011',
|
|
33
|
+
}"
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
<div
|
|
37
|
+
class="absolute translate-y-1/2 bottom-1/2 flex items-center justify-center text-white text-center text-[14px] whitespace-nowrap min-w-[60px] rounded-[6px] bluevue-elevation-1 z-50 h-3/4"
|
|
38
|
+
:class="isEditingCurrentSliderValue ? 'pointer-events-auto' : 'pointer-events-none select-none'"
|
|
39
|
+
:style="{
|
|
40
|
+
left: pillLeft,
|
|
41
|
+
marginLeft: '5px',
|
|
42
|
+
backgroundColor: color || 'var(--bluevue-primary)',
|
|
43
|
+
}"
|
|
44
|
+
>
|
|
45
|
+
<div v-if="!isEditingCurrentSliderValue">
|
|
46
|
+
<p
|
|
47
|
+
class="font-bold leading-none select-none"
|
|
48
|
+
draggable="false"
|
|
49
|
+
>
|
|
50
|
+
{{ formatDisplay ? formatDisplay(scaledValue) : scaledValue.toFixed(defaultDecimals) }}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
<div v-else>
|
|
54
|
+
<input
|
|
55
|
+
ref="editInput"
|
|
56
|
+
v-model.number="editedDisplayValue"
|
|
57
|
+
type="number"
|
|
58
|
+
:min="displayMin"
|
|
59
|
+
:max="displayMax"
|
|
60
|
+
:step="displayStep"
|
|
61
|
+
autofocus
|
|
62
|
+
class="bg-white border border-gray-300 rounded px-1 py-0.5"
|
|
63
|
+
@input="clampEditedValue"
|
|
64
|
+
@keydown="handleValueChange"
|
|
65
|
+
@blur="isEditingCurrentSliderValue = false"
|
|
66
|
+
>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<input
|
|
70
|
+
v-model.number="currentSliderValue"
|
|
71
|
+
type="range"
|
|
72
|
+
class="absolute inset-0 w-full h-full opacity-0"
|
|
73
|
+
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
|
|
74
|
+
style="width: 95%; left: 2.5%"
|
|
75
|
+
:min="min"
|
|
76
|
+
:max="max"
|
|
77
|
+
:step="rawStep"
|
|
78
|
+
:disabled="disabled || isEditingCurrentSliderValue"
|
|
79
|
+
@input="onSliderInput"
|
|
80
|
+
@change="onSliderChange"
|
|
81
|
+
@pointerdown="startInteracting"
|
|
82
|
+
@keydown="onRangeKeydown"
|
|
83
|
+
@keyup="onRangeKeyup"
|
|
84
|
+
@blur="endInteracting"
|
|
85
|
+
@dblclick="isEditingCurrentSliderValue = true"
|
|
86
|
+
>
|
|
87
|
+
<p
|
|
88
|
+
class="absolute left-[5px] top-1/2 -translate-y-1/2 min-w-[30px] text-[12px] font-medium text-center whitespace-nowrap z-10 pointer-events-none"
|
|
89
|
+
:class="theme === 'dark' ? 'text-[#ffffff44]' : 'text-[#00000066]'"
|
|
90
|
+
>
|
|
91
|
+
{{ labelMinDisplay }}
|
|
92
|
+
</p>
|
|
93
|
+
<p
|
|
94
|
+
class="absolute right-[5px] top-1/2 -translate-y-1/2 min-w-[30px] text-[12px] font-medium text-center whitespace-nowrap z-10 pointer-events-none"
|
|
95
|
+
:class="theme === 'dark' ? 'text-[#ffffff44]' : 'text-[#00000066]'"
|
|
96
|
+
>
|
|
97
|
+
{{ labelMaxDisplay }}
|
|
98
|
+
</p>
|
|
99
|
+
<div
|
|
100
|
+
v-if="isEditingCurrentSliderValue"
|
|
101
|
+
class="absolute mdi mdi-check mdi-24px"
|
|
102
|
+
style="right: -30px; top: 50%; transform: translateY(-50%); cursor: pointer; color: green"
|
|
103
|
+
@click="isEditingCurrentSliderValue = false"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</template>
|
|
109
|
+
<script setup lang="ts">
|
|
110
|
+
import { ref, watch, onBeforeUnmount, computed } from 'vue'
|
|
111
|
+
|
|
112
|
+
const props = defineProps<{
|
|
113
|
+
/** Pill color override. */
|
|
114
|
+
color?: string
|
|
115
|
+
/** Disable user interaction. Add disabled visual feedback */
|
|
116
|
+
disabled?: boolean
|
|
117
|
+
/** Slider height (default. '30px'). */
|
|
118
|
+
height?: string
|
|
119
|
+
/** Main Label text on the left. */
|
|
120
|
+
label?: string
|
|
121
|
+
/** Custom text for the max label. */
|
|
122
|
+
labelMax?: string
|
|
123
|
+
/** Custom text for the min label. */
|
|
124
|
+
labelMin?: string
|
|
125
|
+
/** Maximum allowed value. */
|
|
126
|
+
max: number
|
|
127
|
+
/** Minimum allowed value. */
|
|
128
|
+
min: number
|
|
129
|
+
/** Name attribute for the range input. */
|
|
130
|
+
name: string
|
|
131
|
+
/** Step increment (default 1). */
|
|
132
|
+
step?: number
|
|
133
|
+
/** Keyboard arrow step multiplier (default 3). */
|
|
134
|
+
keyboardStepMultiplierLimit?: number
|
|
135
|
+
/** 'light' or 'dark' theme. (default 'light')*/
|
|
136
|
+
theme?: 'light' | 'dark' | 'transparent'
|
|
137
|
+
/** Container width (default '100%'). */
|
|
138
|
+
width?: string
|
|
139
|
+
/** Model value for v-model */
|
|
140
|
+
modelValue: number | null
|
|
141
|
+
/** Function to scale the internal percentage value to a display value */
|
|
142
|
+
scaleFn?: (raw: number) => number
|
|
143
|
+
/** Function to unscale the displayed value to the internal percentage value */
|
|
144
|
+
unscaleFn?: (scaled: number) => number
|
|
145
|
+
/** Function format scaled values, converting them to strings */
|
|
146
|
+
formatDisplay?: (scaled: number) => string
|
|
147
|
+
}>()
|
|
148
|
+
|
|
149
|
+
const emit = defineEmits<{
|
|
150
|
+
(e: 'update:modelValue', value: number | null): void
|
|
151
|
+
}>()
|
|
152
|
+
|
|
153
|
+
const decimalsFromStep = (step: number): number => {
|
|
154
|
+
if (!Number.isFinite(step) || step <= 0) return 0
|
|
155
|
+
const s = step.toString()
|
|
156
|
+
if (s.includes('e-')) {
|
|
157
|
+
const exponent = parseInt(s.split('e-')[1] ?? '0', 10)
|
|
158
|
+
return Math.min(6, Math.max(0, exponent))
|
|
159
|
+
}
|
|
160
|
+
const [, frac = ''] = s.split('.')
|
|
161
|
+
return Math.min(6, frac.length)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Raw value (internal logic always uses this)
|
|
165
|
+
const currentSliderValue = ref<number>(props.modelValue ?? props.min)
|
|
166
|
+
const lastSentValue = ref<number>(currentSliderValue.value)
|
|
167
|
+
|
|
168
|
+
// Editing state
|
|
169
|
+
const isEditingCurrentSliderValue = ref(false)
|
|
170
|
+
const isInteracting = ref(false)
|
|
171
|
+
const editedDisplayValue = ref<number>(0)
|
|
172
|
+
|
|
173
|
+
// Derived display values
|
|
174
|
+
const scaledValue = computed(() =>
|
|
175
|
+
props.scaleFn ? props.scaleFn(currentSliderValue.value) : currentSliderValue.value
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const labelMinDisplay = computed(() =>
|
|
179
|
+
props.labelMin
|
|
180
|
+
? props.labelMin
|
|
181
|
+
: props.formatDisplay
|
|
182
|
+
? props.formatDisplay(props.scaleFn ? props.scaleFn(props.min) : props.min)
|
|
183
|
+
: props.min
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const labelMaxDisplay = computed(() =>
|
|
187
|
+
props.labelMax
|
|
188
|
+
? props.labelMax
|
|
189
|
+
: props.formatDisplay
|
|
190
|
+
? props.formatDisplay(props.scaleFn ? props.scaleFn(props.max) : props.max)
|
|
191
|
+
: props.max
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const displayValue = computed(() => {
|
|
195
|
+
const raw = currentSliderValue.value
|
|
196
|
+
return props.scaleFn ? props.scaleFn(raw) : raw
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const displayMin = computed(() => props.scaleFn ? props.scaleFn(props.min) : props.min)
|
|
200
|
+
const displayMax = computed(() => props.scaleFn ? props.scaleFn(props.max) : props.max)
|
|
201
|
+
|
|
202
|
+
const rawStep = computed(() => props.step ?? 0.1)
|
|
203
|
+
|
|
204
|
+
const keyboardStepMultiplier = computed(() => {
|
|
205
|
+
const multiplier = props.keyboardStepMultiplierLimit ?? 3
|
|
206
|
+
return Number.isFinite(multiplier) && multiplier >= 1 ? multiplier : 3
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Ballistic keyboard stepping:
|
|
210
|
+
// Start at 1x step and ease up toward `keyboardStepMultiplier` while
|
|
211
|
+
// the user keeps pressing/holding an arrow key.
|
|
212
|
+
const keyboardBallisticLastTs = ref<number>(0)
|
|
213
|
+
const keyboardBallisticStreak = ref<number>(0)
|
|
214
|
+
const keyboardBallisticDirection = ref<number>(0)
|
|
215
|
+
|
|
216
|
+
const resetKeyboardBallistics = (): void => {
|
|
217
|
+
keyboardBallisticLastTs.value = 0
|
|
218
|
+
keyboardBallisticStreak.value = 0
|
|
219
|
+
keyboardBallisticDirection.value = 0
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ballisticMultiplier = (direction: number): number => {
|
|
223
|
+
const now = Date.now()
|
|
224
|
+
const max = keyboardStepMultiplier.value
|
|
225
|
+
|
|
226
|
+
// A new burst starts if the user pauses or changes direction.
|
|
227
|
+
const sameBurst =
|
|
228
|
+
keyboardBallisticDirection.value === direction &&
|
|
229
|
+
now - keyboardBallisticLastTs.value <= 250
|
|
230
|
+
|
|
231
|
+
if (!sameBurst) keyboardBallisticStreak.value = 0
|
|
232
|
+
|
|
233
|
+
keyboardBallisticStreak.value += 1
|
|
234
|
+
keyboardBallisticLastTs.value = now
|
|
235
|
+
keyboardBallisticDirection.value = direction
|
|
236
|
+
|
|
237
|
+
// Ramp to max over a handful of repeats, with an ease-out curve.
|
|
238
|
+
const rampRepeats = 6
|
|
239
|
+
const t = Math.min(1, (keyboardBallisticStreak.value - 1) / rampRepeats)
|
|
240
|
+
const eased = 1 - Math.pow(1 - t, 2)
|
|
241
|
+
|
|
242
|
+
return 1 + eased * (max - 1)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const displayStep = computed(() => {
|
|
246
|
+
if (!props.scaleFn) return rawStep.value
|
|
247
|
+
const a = props.scaleFn(props.min)
|
|
248
|
+
const b = props.scaleFn(props.min + rawStep.value)
|
|
249
|
+
const delta = Math.abs(b - a)
|
|
250
|
+
return delta > 0 ? delta : rawStep.value
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const defaultDecimals = computed(() => decimalsFromStep(displayStep.value))
|
|
254
|
+
|
|
255
|
+
const approxEqual = (a: number, b: number): boolean => {
|
|
256
|
+
const epsilon = Math.max(rawStep.value / 10, 1e-4)
|
|
257
|
+
return Math.abs(a - b) <= epsilon
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Pill position logic
|
|
261
|
+
const fillWidth = computed(() => {
|
|
262
|
+
const val = currentSliderValue.value
|
|
263
|
+
return ((val - props.min) / (props.max - props.min)) * 100
|
|
264
|
+
})
|
|
265
|
+
const staticFillWidth = ref<number>(0)
|
|
266
|
+
|
|
267
|
+
const pillLeft = computed(() =>
|
|
268
|
+
`calc((100% - 70px) * ${
|
|
269
|
+
(isEditingCurrentSliderValue.value
|
|
270
|
+
? staticFillWidth.value
|
|
271
|
+
: fillWidth.value) / 100})`
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
const sendValue = (val: number) => {
|
|
275
|
+
if (val === lastSentValue.value) return
|
|
276
|
+
lastSentValue.value = val
|
|
277
|
+
emit('update:modelValue', val)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const commitLockValue = ref<number | null>(null)
|
|
281
|
+
let commitLockTimeout: number | null = null
|
|
282
|
+
const commitLockUntilMs = ref<number>(0)
|
|
283
|
+
|
|
284
|
+
const clearCommitLock = (): void => {
|
|
285
|
+
commitLockValue.value = null
|
|
286
|
+
commitLockUntilMs.value = 0
|
|
287
|
+
if (commitLockTimeout) {
|
|
288
|
+
clearTimeout(commitLockTimeout)
|
|
289
|
+
commitLockTimeout = null
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const setCommitLock = (val: number): void => {
|
|
294
|
+
const lockMs = 2000
|
|
295
|
+
commitLockValue.value = val
|
|
296
|
+
commitLockUntilMs.value = Date.now() + lockMs
|
|
297
|
+
if (commitLockTimeout) clearTimeout(commitLockTimeout)
|
|
298
|
+
commitLockTimeout = window.setTimeout(() => {
|
|
299
|
+
commitLockTimeout = null
|
|
300
|
+
commitLockValue.value = null
|
|
301
|
+
commitLockUntilMs.value = 0
|
|
302
|
+
}, lockMs)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Slider input → update raw
|
|
306
|
+
const onSliderInput = (): void => {
|
|
307
|
+
const clamped = Math.min(Math.max(currentSliderValue.value, props.min), props.max)
|
|
308
|
+
currentSliderValue.value = clamped
|
|
309
|
+
throttleSendValue(clamped)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const onSliderChange = (): void => {
|
|
313
|
+
endInteracting()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Clamp during input
|
|
317
|
+
const clampEditedValue = () => {
|
|
318
|
+
editedDisplayValue.value = Math.min(Math.max(editedDisplayValue.value, displayMin.value), displayMax.value)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let throttleTimeout: number | null = null
|
|
322
|
+
let pendingValue: number | null = null
|
|
323
|
+
|
|
324
|
+
const throttleSendValue = (val: number) => {
|
|
325
|
+
pendingValue = val
|
|
326
|
+
if (throttleTimeout) return
|
|
327
|
+
|
|
328
|
+
const leading = pendingValue
|
|
329
|
+
pendingValue = null
|
|
330
|
+
sendValue(leading)
|
|
331
|
+
|
|
332
|
+
throttleTimeout = window.setTimeout(() => {
|
|
333
|
+
throttleTimeout = null
|
|
334
|
+
if (pendingValue === null) return
|
|
335
|
+
const trailing = pendingValue
|
|
336
|
+
pendingValue = null
|
|
337
|
+
sendValue(trailing)
|
|
338
|
+
}, 500)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const flushPendingValue = (): void => {
|
|
342
|
+
if (throttleTimeout) {
|
|
343
|
+
clearTimeout(throttleTimeout)
|
|
344
|
+
throttleTimeout = null
|
|
345
|
+
}
|
|
346
|
+
if (pendingValue === null) return
|
|
347
|
+
const toSend = pendingValue
|
|
348
|
+
pendingValue = null
|
|
349
|
+
sendValue(toSend)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const endInteracting = (): void => {
|
|
353
|
+
isInteracting.value = false
|
|
354
|
+
|
|
355
|
+
const clamped = Math.min(Math.max(currentSliderValue.value, props.min), props.max)
|
|
356
|
+
currentSliderValue.value = clamped
|
|
357
|
+
pendingValue = clamped
|
|
358
|
+
flushPendingValue()
|
|
359
|
+
sendValue(clamped)
|
|
360
|
+
setCommitLock(clamped)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const handlePointerUp = (): void => endInteracting()
|
|
364
|
+
const handlePointerCancel = (): void => endInteracting()
|
|
365
|
+
|
|
366
|
+
const startInteracting = (): void => {
|
|
367
|
+
if (props.disabled || isEditingCurrentSliderValue.value) return
|
|
368
|
+
isInteracting.value = true
|
|
369
|
+
clearCommitLock()
|
|
370
|
+
window.addEventListener('pointerup', handlePointerUp, { once: true })
|
|
371
|
+
window.addEventListener('pointercancel', handlePointerCancel, { once: true })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const isArrowKey = (key: string): boolean =>
|
|
375
|
+
key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown'
|
|
376
|
+
|
|
377
|
+
const onRangeKeydown = (e: KeyboardEvent): void => {
|
|
378
|
+
if (props.disabled || isEditingCurrentSliderValue.value) return
|
|
379
|
+
if (!isArrowKey(e.key)) return
|
|
380
|
+
|
|
381
|
+
// Override native range keyboard step so we can apply ballistics.
|
|
382
|
+
e.preventDefault()
|
|
383
|
+
|
|
384
|
+
const direction = (e.key === 'ArrowRight' || e.key === 'ArrowUp') ? 1 : -1
|
|
385
|
+
const step = rawStep.value
|
|
386
|
+
|
|
387
|
+
if (!Number.isFinite(step) || step <= 0) return
|
|
388
|
+
|
|
389
|
+
const multiplier = ballisticMultiplier(direction)
|
|
390
|
+
const keyboardStep = step * multiplier
|
|
391
|
+
|
|
392
|
+
const next = currentSliderValue.value + direction * keyboardStep
|
|
393
|
+
const clamped = Math.min(Math.max(next, props.min), props.max)
|
|
394
|
+
|
|
395
|
+
// Align to step grid to avoid float drift.
|
|
396
|
+
const stepsFromMin = Math.round((clamped - props.min) / step)
|
|
397
|
+
const aligned = Math.min(
|
|
398
|
+
Math.max(props.min + stepsFromMin * step, props.min),
|
|
399
|
+
props.max,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
currentSliderValue.value = aligned
|
|
403
|
+
throttleSendValue(aligned)
|
|
404
|
+
|
|
405
|
+
if (!isInteracting.value) {
|
|
406
|
+
isInteracting.value = true
|
|
407
|
+
clearCommitLock()
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const onRangeKeyup = (e: KeyboardEvent): void => {
|
|
412
|
+
if (!isArrowKey(e.key)) return
|
|
413
|
+
resetKeyboardBallistics()
|
|
414
|
+
endInteracting()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Keyboard handling
|
|
418
|
+
const handleValueChange = (e: KeyboardEvent): void => {
|
|
419
|
+
if (e.key === 'Escape') {
|
|
420
|
+
isEditingCurrentSliderValue.value = false
|
|
421
|
+
editedDisplayValue.value = props.scaleFn ? props.scaleFn(lastSentValue.value) : lastSentValue.value
|
|
422
|
+
currentSliderValue.value = lastSentValue.value
|
|
423
|
+
} else if (e.key === 'Enter') {
|
|
424
|
+
isEditingCurrentSliderValue.value = false
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Sync from parent
|
|
429
|
+
watch(
|
|
430
|
+
() => props.modelValue,
|
|
431
|
+
(v) => {
|
|
432
|
+
const next = v ?? props.min
|
|
433
|
+
if (isEditingCurrentSliderValue.value || isInteracting.value) return
|
|
434
|
+
if (
|
|
435
|
+
commitLockValue.value !== null &&
|
|
436
|
+
Date.now() < commitLockUntilMs.value &&
|
|
437
|
+
!approxEqual(next, commitLockValue.value)
|
|
438
|
+
) {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
currentSliderValue.value = next
|
|
442
|
+
},
|
|
443
|
+
{ immediate: true }
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
watch(isEditingCurrentSliderValue, (isEditing) => {
|
|
447
|
+
if (isEditing) {
|
|
448
|
+
editedDisplayValue.value = displayValue.value
|
|
449
|
+
staticFillWidth.value = fillWidth.value
|
|
450
|
+
} else {
|
|
451
|
+
const raw = props.unscaleFn ? props.unscaleFn(editedDisplayValue.value) : editedDisplayValue.value
|
|
452
|
+
currentSliderValue.value = Math.min(Math.max(raw, props.min), props.max)
|
|
453
|
+
sendValue(currentSliderValue.value)
|
|
454
|
+
setCommitLock(currentSliderValue.value)
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
onBeforeUnmount(() => {
|
|
459
|
+
if (throttleTimeout) clearTimeout(throttleTimeout)
|
|
460
|
+
if (commitLockTimeout) clearTimeout(commitLockTimeout)
|
|
461
|
+
window.removeEventListener('pointerup', handlePointerUp)
|
|
462
|
+
window.removeEventListener('pointercancel', handlePointerCancel)
|
|
463
|
+
})
|
|
464
|
+
</script>
|