Allow Numeric to change off from an invalid value (and not get stuck on it). Cleanup of the component.

This commit is contained in:
Leo Vasanko 2025-08-25 22:23:16 -06:00
parent 29246af591
commit 579d01dfd8

View File

@ -2,27 +2,25 @@
<div <div
ref="rootEl" ref="rootEl"
class="mini-stepper drag-mode" class="mini-stepper drag-mode"
:class="[extraClass, { dragging, 'pointer-locked': pointerLocked }]" :class="[extraClass, { dragging }]"
:aria-label="ariaLabel" :aria-label="ariaLabel"
role="spinbutton" role="spinbutton"
:aria-valuemin="minValue" :aria-valuemin="minValue"
:aria-valuemax="maxValue" :aria-valuemax="maxValue"
:aria-valuenow="isPrefix(current) ? undefined : current" :aria-valuenow="isPrefix(model) ? undefined : model"
:aria-valuetext="display" :aria-valuetext="display"
tabindex="0" tabindex="0"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
@keydown="onKey" @keydown="onKey"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
> >
<span class="value" :title="String(current)">{{ display }}</span> <span class="value" :title="String(model)">{{ display }}</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, onBeforeUnmount } from 'vue' import { computed, ref } from 'vue'
const model = defineModel({ default: 0 })
const model = defineModel({ type: Number, default: 0 })
const props = defineProps({ const props = defineProps({
min: { type: Number, default: 0 }, min: { type: Number, default: 0 },
max: { type: Number, default: 999 }, max: { type: Number, default: 999 },
@ -37,85 +35,41 @@ const props = defineProps({
numberPostfix: { type: String, default: '' }, numberPostfix: { type: String, default: '' },
clamp: { type: Boolean, default: true }, clamp: { type: Boolean, default: true },
pixelsPerStep: { type: Number, default: 16 }, pixelsPerStep: { type: Number, default: 16 },
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
axis: { type: String, default: 'x' }, axis: { type: String, default: 'x' },
ariaLabel: { type: String, default: '' }, ariaLabel: { type: String, default: '' },
extraClass: { type: String, default: '' }, extraClass: { type: String, default: '' },
}) })
const minValue = computed(() => props.min) const minValue = computed(() => props.min)
const maxValue = computed(() => props.max) const maxValue = computed(() => props.max)
const isPrefix = (value) => props.prefixValues.some((p) => p.value === value)
// Helper to check if a value is in the prefix values const getPrefixDisplay = (value) =>
const isPrefix = (value) => { props.prefixValues.find((p) => p.value === value)?.display ?? null
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 allValidValues = computed(() => {
const prefixVals = props.prefixValues.map((p) => p.value) const prefixVals = props.prefixValues.map((p) => p.value)
const numericVals = [] const numericVals = []
for (let i = props.min; i <= props.max; i += props.step) { for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i)
numericVals.push(i)
}
return [...prefixVals, ...numericVals] 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 display = computed(() => {
const prefixDisplay = getPrefixDisplay(current.value) const prefixDisplay = getPrefixDisplay(model.value)
if (prefixDisplay !== null) { if (prefixDisplay !== null) return prefixDisplay
// For prefix values, show only the display text without number prefix/postfix return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}`
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 dragging = ref(false)
const rootEl = ref(null) const rootEl = ref(null)
let startX = 0 let startX = 0
let startY = 0 let startY = 0
let accumX = 0 // accumulated horizontal movement since last applied step (works for both locked & unlocked) let accumX = 0
let lastClientX = 0 // previous clientX when not pointer locked let lastClientX = 0
const pointerLocked = ref(false) const pointerLocked = ref(false)
function updatePointerLocked() { function updatePointerLocked() {
pointerLocked.value = pointerLocked.value =
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
// Reset baseline if lock just engaged
if (pointerLocked.value) { if (pointerLocked.value) {
accumX = 0 accumX = 0
startX = 0 // not used while locked startX = 0
} }
} }
function addPointerLockListeners() { function addPointerLockListeners() {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
document.addEventListener('pointerlockchange', updatePointerLocked) document.addEventListener('pointerlockchange', updatePointerLocked)
@ -126,7 +80,6 @@ function removePointerLockListeners() {
document.removeEventListener('pointerlockchange', updatePointerLocked) document.removeEventListener('pointerlockchange', updatePointerLocked)
document.removeEventListener('pointerlockerror', updatePointerLocked) document.removeEventListener('pointerlockerror', updatePointerLocked)
} }
function onPointerDown(e) { function onPointerDown(e) {
e.preventDefault() e.preventDefault()
startX = e.clientX startX = e.clientX
@ -150,33 +103,44 @@ function onPointerDown(e) {
function onPointerMove(e) { function onPointerMove(e) {
if (!dragging.value) return if (!dragging.value) return
if (e.pointerType === 'touch') e.preventDefault() if (e.pointerType === 'touch') e.preventDefault()
let dx let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX
if (pointerLocked.value) { if (!pointerLocked.value) lastClientX = e.clientX
dx = e.movementX || 0
} else {
dx = e.clientX - lastClientX
lastClientX = e.clientX
}
if (!dx) return if (!dx) return
accumX += dx accumX += dx
const stepSize = props.pixelsPerStep || 1 const stepSize = props.pixelsPerStep || 1
let steps = Math.trunc(accumX / stepSize) let steps = Math.trunc(accumX / stepSize)
if (steps === 0) return if (steps === 0) return
// Apply steps relative to current value index each time (dynamic baseline) and keep leftover pixels
const applySteps = (count) => { const applySteps = (count) => {
const currentIndex = allValidValues.value.indexOf(current.value) if (!count) return
if (currentIndex === -1) return let direction = count > 0 ? 1 : -1
let targetIndex = currentIndex + count let remaining = Math.abs(count)
if (props.clamp) { let curVal = model.value
targetIndex = Math.max(0, Math.min(targetIndex, allValidValues.value.length - 1)) const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
let idx = allValidValues.value.indexOf(curVal)
if (idx === -1) {
if (!isNumeric) {
curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min
} else {
if (direction > 0) curVal = props.min
else
curVal = props.prefixValues.length
? props.prefixValues[props.prefixValues.length - 1].value
: props.min
} }
if (targetIndex >= 0 && targetIndex < allValidValues.value.length) { remaining--
const next = allValidValues.value[targetIndex]
if (next !== current.value) current.value = next
} }
while (remaining > 0) {
idx = allValidValues.value.indexOf(curVal)
if (idx === -1) break
let targetIdx = idx + direction
if (props.clamp) targetIdx = Math.max(0, Math.min(targetIdx, allValidValues.value.length - 1))
if (targetIdx < 0 || targetIdx >= allValidValues.value.length || targetIdx === idx) break
curVal = allValidValues.value[targetIdx]
remaining--
}
model.value = curVal
} }
applySteps(steps) applySteps(steps)
// Remove consumed distance (works even if clamped; leftover ensures responsiveness keeps consistent feel)
accumX -= steps * stepSize accumX -= steps * stepSize
} }
function endDragListeners() { function endDragListeners() {
@ -196,52 +160,43 @@ function onPointerCancel() {
dragging.value = false dragging.value = false
endDragListeners() endDragListeners()
} }
function onKey(e) { function onKey(e) {
const key = e.key const key = e.key
let handled = false let handled = false
let newValue = null let newValue = null
const currentIndex = allValidValues.value.indexOf(model.value)
// Find current value index in all valid values
const currentIndex = allValidValues.value.indexOf(current.value)
switch (key) { switch (key) {
case 'ArrowRight': case 'ArrowRight':
case 'ArrowUp': case 'ArrowUp':
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) { if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1)
newValue = allValidValues.value[currentIndex + 1] newValue = allValidValues.value[currentIndex + 1]
} else if (currentIndex === -1) { else if (currentIndex === -1) {
// Current value not in list, try to increment normally const curVal = model.value
newValue = current.value + props.step const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value
else newValue = props.min
} }
handled = true handled = true
break break
case 'ArrowLeft': case 'ArrowLeft':
case 'ArrowDown': case 'ArrowDown':
if (currentIndex !== -1 && currentIndex > 0) { if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1]
newValue = allValidValues.value[currentIndex - 1] else if (currentIndex === -1)
} else if (currentIndex === -1) { newValue = props.prefixValues.length
// Current value not in list, try to decrement normally ? props.prefixValues[props.prefixValues.length - 1].value
newValue = current.value - props.step : props.min
}
handled = true handled = true
break break
case 'PageUp': case 'PageUp':
if (currentIndex !== -1) { if (currentIndex !== -1)
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1) newValue =
newValue = allValidValues.value[newIndex] allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)]
} else { else newValue = model.value + props.step * 10
newValue = current.value + props.step * 10
}
handled = true handled = true
break break
case 'PageDown': case 'PageDown':
if (currentIndex !== -1) { if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)]
const newIndex = Math.max(currentIndex - 10, 0) else newValue = model.value - props.step * 10
newValue = allValidValues.value[newIndex]
} else {
newValue = current.value - props.step * 10
}
handled = true handled = true
break break
case 'Home': case 'Home':
@ -253,31 +208,30 @@ function onKey(e) {
handled = true handled = true
break break
} }
if (newValue !== null) model.value = newValue
if (newValue !== null) {
current.value = newValue
}
if (handled) { if (handled) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
} }
} }
function onWheel(e) { function onWheel(e) {
// Inverted: deltaY < 0 => decrement, > 0 => increment
const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0 const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0
if (direction === 0) return if (direction === 0) return
// Use current index in allValidValues list (prefix values included) const idx = allValidValues.value.indexOf(model.value)
const idx = allValidValues.value.indexOf(current.value)
if (idx !== -1) { if (idx !== -1) {
const nextIdx = idx + direction const nextIdx = idx + direction
if (nextIdx >= 0 && nextIdx < allValidValues.value.length) { if (nextIdx >= 0 && nextIdx < allValidValues.value.length)
current.value = allValidValues.value[nextIdx] model.value = allValidValues.value[nextIdx]
}
} else { } else {
// Fallback numeric adjustment if not found const curVal = model.value
current.value = current.value + direction * props.step const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
if (!isNumeric)
model.value = props.prefixValues.length ? props.prefixValues[0].value : props.min
else if (direction > 0) model.value = props.min
else
model.value = props.prefixValues.length
? props.prefixValues[props.prefixValues.length - 1].value
: props.min
} }
} }
</script> </script>
@ -307,7 +261,4 @@ function onWheel(e) {
.mini-stepper.drag-mode.dragging { .mini-stepper.drag-mode.dragging {
cursor: grabbing; cursor: grabbing;
} }
.mini-stepper.drag-mode.pointer-locked.dragging {
cursor: none; /* hide cursor for infinite drag */
}
</style> </style>