@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.
@@ -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>