Allow Numeric to change off from an invalid value (and not get stuck on it). Cleanup of the component.
This commit is contained in:
parent
29246af591
commit
579d01dfd8
@ -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
|
||||||
|
}
|
||||||
|
remaining--
|
||||||
}
|
}
|
||||||
if (targetIndex >= 0 && targetIndex < allValidValues.value.length) {
|
while (remaining > 0) {
|
||||||
const next = allValidValues.value[targetIndex]
|
idx = allValidValues.value.indexOf(curVal)
|
||||||
if (next !== current.value) current.value = next
|
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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user