252 lines
7.0 KiB
Vue
252 lines
7.0 KiB
Vue
<template>
|
|
<div
|
|
ref="rootEl"
|
|
class="mini-stepper drag-mode"
|
|
:class="[extraClass, { dragging }]"
|
|
:aria-label="ariaLabel"
|
|
role="spinbutton"
|
|
:aria-valuemin="minValue"
|
|
:aria-valuemax="maxValue"
|
|
:aria-valuenow="isPrefix(current) ? undefined : current"
|
|
:aria-valuetext="display"
|
|
tabindex="0"
|
|
@pointerdown="onPointerDown"
|
|
@keydown="onKey"
|
|
>
|
|
<span class="value" :title="String(current)">{{ display }}</span>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref } from 'vue'
|
|
|
|
const model = defineModel({ type: Number, default: 0 })
|
|
|
|
const props = defineProps({
|
|
min: { type: Number, default: 0 },
|
|
max: { type: Number, default: 999 },
|
|
step: { type: Number, default: 1 },
|
|
prefixValues: {
|
|
type: Array,
|
|
default: () => [],
|
|
validator: (arr) =>
|
|
arr.every((item) => typeof item === 'object' && 'value' in item && 'display' in item),
|
|
},
|
|
numberPrefix: { type: String, default: '' },
|
|
numberPostfix: { type: String, default: '' },
|
|
clamp: { type: Boolean, default: true },
|
|
pixelsPerStep: { type: Number, default: 16 },
|
|
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
|
|
axis: { type: String, default: 'x' },
|
|
ariaLabel: { type: String, default: '' },
|
|
extraClass: { type: String, default: '' },
|
|
})
|
|
|
|
const minValue = computed(() => props.min)
|
|
const maxValue = computed(() => props.max)
|
|
|
|
// Helper to check if a value is in the prefix values
|
|
const isPrefix = (value) => {
|
|
return props.prefixValues.some((prefix) => prefix.value === value)
|
|
}
|
|
|
|
// Helper to get the display for a prefix value
|
|
const getPrefixDisplay = (value) => {
|
|
const prefix = props.prefixValues.find((p) => p.value === value)
|
|
return prefix ? prefix.display : null
|
|
}
|
|
|
|
// Get all valid values in order: prefixValues, then min to max
|
|
const allValidValues = computed(() => {
|
|
const prefixVals = props.prefixValues.map((p) => p.value)
|
|
const numericVals = []
|
|
for (let i = props.min; i <= props.max; i += props.step) {
|
|
numericVals.push(i)
|
|
}
|
|
return [...prefixVals, ...numericVals]
|
|
})
|
|
|
|
const current = computed({
|
|
get() {
|
|
return model.value
|
|
},
|
|
set(v) {
|
|
if (props.clamp) {
|
|
// If it's a prefix value, allow it
|
|
if (isPrefix(v)) {
|
|
model.value = v
|
|
return
|
|
}
|
|
// Otherwise clamp to numeric range
|
|
if (v < props.min) v = props.min
|
|
if (v > props.max) v = props.max
|
|
}
|
|
model.value = v
|
|
},
|
|
})
|
|
|
|
const display = computed(() => {
|
|
const prefixDisplay = getPrefixDisplay(current.value)
|
|
if (prefixDisplay !== null) {
|
|
// For prefix values, show only the display text without number prefix/postfix
|
|
return prefixDisplay
|
|
}
|
|
// For numeric values, include prefix and postfix
|
|
const numericValue = String(current.value)
|
|
return `${props.numberPrefix}${numericValue}${props.numberPostfix}`
|
|
})
|
|
|
|
// Drag handling
|
|
const dragging = ref(false)
|
|
const rootEl = ref(null)
|
|
let startX = 0
|
|
let startY = 0
|
|
let startVal = 0
|
|
|
|
function onPointerDown(e) {
|
|
e.preventDefault()
|
|
startX = e.clientX
|
|
startY = e.clientY
|
|
startVal = current.value
|
|
dragging.value = true
|
|
try {
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
} catch {}
|
|
rootEl.value?.addEventListener('pointermove', onPointerMove)
|
|
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true })
|
|
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true })
|
|
}
|
|
function onPointerMove(e) {
|
|
if (!dragging.value) return
|
|
// Prevent page scroll on touch while dragging
|
|
if (e.pointerType === 'touch') e.preventDefault()
|
|
const primary = e.clientX - startX // horizontal only
|
|
const steps = Math.trunc(primary / props.pixelsPerStep)
|
|
|
|
// Find current value index in all valid values
|
|
const currentIndex = allValidValues.value.indexOf(startVal)
|
|
if (currentIndex === -1) return // shouldn't happen
|
|
|
|
const newIndex = currentIndex + steps
|
|
if (props.clamp) {
|
|
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1))
|
|
const next = allValidValues.value[clampedIndex]
|
|
if (next !== current.value) current.value = next
|
|
} else {
|
|
if (newIndex >= 0 && newIndex < allValidValues.value.length) {
|
|
const next = allValidValues.value[newIndex]
|
|
if (next !== current.value) current.value = next
|
|
}
|
|
}
|
|
}
|
|
function endDragListeners() {
|
|
rootEl.value?.removeEventListener('pointermove', onPointerMove)
|
|
}
|
|
function onPointerUp() {
|
|
dragging.value = false
|
|
endDragListeners()
|
|
}
|
|
function onPointerCancel() {
|
|
dragging.value = false
|
|
endDragListeners()
|
|
}
|
|
|
|
function onKey(e) {
|
|
const key = e.key
|
|
let handled = false
|
|
let newValue = null
|
|
|
|
// Find current value index in all valid values
|
|
const currentIndex = allValidValues.value.indexOf(current.value)
|
|
|
|
switch (key) {
|
|
case 'ArrowRight':
|
|
case 'ArrowUp':
|
|
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) {
|
|
newValue = allValidValues.value[currentIndex + 1]
|
|
} else if (currentIndex === -1) {
|
|
// Current value not in list, try to increment normally
|
|
newValue = current.value + props.step
|
|
}
|
|
handled = true
|
|
break
|
|
case 'ArrowLeft':
|
|
case 'ArrowDown':
|
|
if (currentIndex !== -1 && currentIndex > 0) {
|
|
newValue = allValidValues.value[currentIndex - 1]
|
|
} else if (currentIndex === -1) {
|
|
// Current value not in list, try to decrement normally
|
|
newValue = current.value - props.step
|
|
}
|
|
handled = true
|
|
break
|
|
case 'PageUp':
|
|
if (currentIndex !== -1) {
|
|
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1)
|
|
newValue = allValidValues.value[newIndex]
|
|
} else {
|
|
newValue = current.value + props.step * 10
|
|
}
|
|
handled = true
|
|
break
|
|
case 'PageDown':
|
|
if (currentIndex !== -1) {
|
|
const newIndex = Math.max(currentIndex - 10, 0)
|
|
newValue = allValidValues.value[newIndex]
|
|
} else {
|
|
newValue = current.value - props.step * 10
|
|
}
|
|
handled = true
|
|
break
|
|
case 'Home':
|
|
newValue = allValidValues.value[0] || props.min
|
|
handled = true
|
|
break
|
|
case 'End':
|
|
newValue = allValidValues.value[allValidValues.value.length - 1] || props.max
|
|
handled = true
|
|
break
|
|
}
|
|
|
|
if (newValue !== null) {
|
|
current.value = newValue
|
|
}
|
|
|
|
if (handled) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mini-stepper.drag-mode {
|
|
cursor: ew-resize;
|
|
user-select: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 0.4rem;
|
|
gap: 0.25rem;
|
|
border: 1px solid var(--input-border, var(--muted));
|
|
background: var(--panel-alt);
|
|
border-radius: 0.4rem;
|
|
min-height: 1.8rem;
|
|
font-variant-numeric: tabular-nums;
|
|
touch-action: none; /* allow custom drag without scrolling */
|
|
}
|
|
.mini-stepper.drag-mode:focus-visible {
|
|
outline: 2px solid var(--input-focus, #2563eb);
|
|
outline-offset: 2px;
|
|
}
|
|
.mini-stepper.drag-mode .value {
|
|
font-weight: 600;
|
|
min-width: 1.6rem;
|
|
text-align: center;
|
|
pointer-events: none;
|
|
}
|
|
.mini-stepper.drag-mode.dragging {
|
|
cursor: grabbing;
|
|
}
|
|
</style>
|