feat: add AppNumberInput component with press-and-hold repeat
This commit is contained in:
135
src/components/AppNumberInput.vue
Normal file
135
src/components/AppNumberInput.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Minus, Plus } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
precision?: number
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
min: 0,
|
||||||
|
max: Infinity,
|
||||||
|
step: 1,
|
||||||
|
precision: 0,
|
||||||
|
prefix: '',
|
||||||
|
suffix: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editValue = ref('')
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
return props.modelValue.toFixed(props.precision)
|
||||||
|
})
|
||||||
|
|
||||||
|
function setValue(val: number) {
|
||||||
|
const clamped = Math.min(props.max, Math.max(props.min, val))
|
||||||
|
const rounded = parseFloat(clamped.toFixed(props.precision))
|
||||||
|
emit('update:modelValue', rounded)
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
setValue(props.modelValue + props.step)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
setValue(props.modelValue - props.step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Press-and-hold ───────────────────────────────────────────────
|
||||||
|
let holdTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let holdInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function startHold(action: () => void) {
|
||||||
|
action()
|
||||||
|
holdTimeout = setTimeout(() => {
|
||||||
|
holdInterval = setInterval(action, 80)
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHold() {
|
||||||
|
if (holdTimeout) { clearTimeout(holdTimeout); holdTimeout = null }
|
||||||
|
if (holdInterval) { clearInterval(holdInterval); holdInterval = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline editing ───────────────────────────────────────────────
|
||||||
|
function startEdit() {
|
||||||
|
isEditing.value = true
|
||||||
|
editValue.value = displayValue.value
|
||||||
|
setTimeout(() => inputRef.value?.select(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitEdit() {
|
||||||
|
isEditing.value = false
|
||||||
|
const parsed = parseFloat(editValue.value)
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
setValue(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
isEditing.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@mousedown.prevent="startHold(decrement)"
|
||||||
|
@mouseup="stopHold"
|
||||||
|
@mouseleave="stopHold"
|
||||||
|
@touchstart.prevent="startHold(decrement)"
|
||||||
|
@touchend="stopHold"
|
||||||
|
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||||
|
:disabled="modelValue <= min"
|
||||||
|
>
|
||||||
|
<Minus class="w-3.5 h-3.5" :stroke-width="2" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isEditing"
|
||||||
|
@click="startEdit"
|
||||||
|
class="min-w-[4rem] text-center text-[0.8125rem] font-mono text-text-primary cursor-text select-none"
|
||||||
|
>
|
||||||
|
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
|
||||||
|
{{ displayValue }}
|
||||||
|
<span v-if="suffix" class="text-text-tertiary ml-0.5">{{ suffix }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="editValue"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
class="w-20 text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg text-[0.8125rem] font-mono text-text-primary focus:outline-none"
|
||||||
|
@blur="commitEdit"
|
||||||
|
@keydown.enter="commitEdit"
|
||||||
|
@keydown.escape="cancelEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@mousedown.prevent="startHold(increment)"
|
||||||
|
@mouseup="stopHold"
|
||||||
|
@mouseleave="stopHold"
|
||||||
|
@touchstart.prevent="startHold(increment)"
|
||||||
|
@touchend="stopHold"
|
||||||
|
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||||
|
:disabled="modelValue >= max"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5" :stroke-width="2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user