227 lines
6.1 KiB
Vue
227 lines
6.1 KiB
Vue
<script setup>
|
|
const props = defineProps({
|
|
min: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
max: {
|
|
type: Number,
|
|
default: 1
|
|
},
|
|
step: {
|
|
type: Number,
|
|
default: 0.01
|
|
},
|
|
value: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
id: {
|
|
type: String,
|
|
},
|
|
label: {
|
|
type: String,
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
const modelValue = ref(props.value);
|
|
|
|
watch(modelValue, (newValue) => {
|
|
emit('update:modelValue', newValue);
|
|
});
|
|
emit('update:modelValue', props.value);
|
|
|
|
function roundValueToStep(number) {
|
|
const clampedNumber = Math.min(Math.max(number, props.min), props.max);
|
|
const quotient = Math.floor((clampedNumber - props.min) / props.step);
|
|
const lowerValue = props.min + quotient * props.step;
|
|
const upperValue = lowerValue + props.step;
|
|
|
|
if (clampedNumber - lowerValue < upperValue - clampedNumber) {
|
|
return lowerValue; // Round down
|
|
} else {
|
|
return upperValue; // Round up
|
|
}
|
|
}
|
|
|
|
const rangeSlider = ref(null);
|
|
const rangeThumb = ref(null);
|
|
const rangeValue = ref({
|
|
real: ref(roundValueToStep(props.value)),
|
|
stepped: ref(roundValueToStep(props.value))
|
|
});
|
|
|
|
const sliderPercentage = ref((rangeValue.value.real / props.max) * 100);
|
|
const steppedSliderPercentage = ref((rangeValue.value.stepped / props.max) * 100)
|
|
const steps = (props.max / props.step)
|
|
|
|
function snapToStep(percentage) {
|
|
const pixelsPerStep = rangeSlider.value.clientWidth / steps;
|
|
let step = roundValueToStep((((percentage / 100) * rangeSlider.value.clientWidth) / pixelsPerStep) * props.step);
|
|
|
|
const split = step.toString().split('.');
|
|
// fix float imprecisions by making sure the number only has at most 3 digits after the decimal point
|
|
if (split.length > 1) {
|
|
split[1] = split[1].slice(0,3);
|
|
step = parseFloat(split.join('.'));
|
|
}
|
|
|
|
rangeValue.value.stepped = step;
|
|
modelValue.value = step;
|
|
|
|
return (rangeValue.value.stepped / props.max) * 100;
|
|
}
|
|
|
|
function calculateSliderPosition(pointerEvent) {
|
|
if (!rangeSlider.value) return;
|
|
let pointerPosFromRangeStart = pointerEvent.clientX - rangeSlider.value.getClientRects()[0].x;
|
|
|
|
if (pointerPosFromRangeStart < 0) {
|
|
pointerPosFromRangeStart = 0;
|
|
}
|
|
|
|
if (pointerPosFromRangeStart > rangeSlider.value.clientWidth) {
|
|
pointerPosFromRangeStart = rangeSlider.value.clientWidth;
|
|
}
|
|
|
|
const value = (pointerPosFromRangeStart / rangeSlider.value.clientWidth);
|
|
|
|
const newSliderPercentage = value * 100;
|
|
|
|
if (newSliderPercentage === sliderPercentage.value) return;
|
|
|
|
updateSliderPosition(newSliderPercentage);
|
|
}
|
|
|
|
function updateSliderPosition(newSliderPercentage) {
|
|
sliderPercentage.value = newSliderPercentage;
|
|
|
|
steppedSliderPercentage.value = snapToStep(newSliderPercentage);
|
|
}
|
|
|
|
function pointerDown(pointerEvent) {
|
|
document.body.addEventListener('pointermove', calculateSliderPosition)
|
|
|
|
calculateSliderPosition(pointerEvent);
|
|
|
|
document.body.addEventListener('pointerup', () => {
|
|
rangeThumb.value.focus();
|
|
document.body.removeEventListener('pointermove', calculateSliderPosition)
|
|
}, { once: true })
|
|
}
|
|
|
|
function keydown(keyboardEvent) {
|
|
if (keyboardEvent.keyCode >= 37 && keyboardEvent.keyCode <= 40) {
|
|
keyboardEvent.preventDefault();
|
|
const direction = (keyboardEvent.key === "ArrowRight" || keyboardEvent.key === "ArrowUp") ? 'forward' : 'backward';
|
|
|
|
if (direction === 'forward') {
|
|
if (rangeValue.value.stepped + props.step > props.max) return;
|
|
rangeValue.value.stepped += props.step;
|
|
} else {
|
|
if (rangeValue.value.stepped - props.step < props.min) return;
|
|
rangeValue.value.stepped -= props.step;
|
|
}
|
|
|
|
return updateSliderPosition((rangeValue.value.stepped / props.max) * 100);
|
|
}
|
|
|
|
if (keyboardEvent.keyCode === 36 || keyboardEvent.keyCode === 35) {
|
|
keyboardEvent.preventDefault();
|
|
|
|
if (keyboardEvent.key === "Home") {
|
|
rangeValue.value.stepped = props.min;
|
|
} else {
|
|
rangeValue.value.stepped = props.max;
|
|
}
|
|
|
|
return updateSliderPosition((rangeValue.value.stepped / props.max) * 100);
|
|
}
|
|
|
|
if (keyboardEvent.keyCode === 33 || keyboardEvent.keyCode === 34) {
|
|
keyboardEvent.preventDefault();
|
|
const direction = (keyboardEvent.key === "PageUp") ? 'forward' : 'backward';
|
|
let multiplier = 2;
|
|
|
|
if (direction === 'forward') {
|
|
if (rangeValue.value.stepped + (props.step * multiplier) > props.max) multiplier = 1;
|
|
if (rangeValue.value.stepped + (props.step * multiplier) > props.max) return;
|
|
rangeValue.value.stepped += (props.step * multiplier);
|
|
} else {
|
|
if (rangeValue.value.stepped - (props.step * multiplier) > props.max) multiplier = 1;
|
|
if (rangeValue.value.stepped - (props.step * multiplier) > props.max) return;
|
|
rangeValue.value.stepped -= (props.step * multiplier);
|
|
}
|
|
|
|
return updateSliderPosition((rangeValue.value.stepped / props.max) * 100);
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.id) {
|
|
document.body.querySelector(`[for="${props.id}"]`).addEventListener("click", () => {
|
|
rangeThumb.value.focus();
|
|
})
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (props.id) {
|
|
document.body.querySelector(`[for="${props.id}"]`).removeEventListener("click", () => {
|
|
rangeThumb.value.focus();
|
|
})
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="vl-range-slider" ref="rangeSlider" @pointerdown="pointerDown($event)">
|
|
<span class="vl-range-track">
|
|
<span class="vl-range-track-filled" :style="'width: ' + steppedSliderPercentage + '%;'">
|
|
</span>
|
|
</span>
|
|
<span class="vl-range-thumb" :aria-label="props.label" :aria-labelledby="props.id" role="slider" ref="rangeThumb"
|
|
:aria-valuemin="props.min" :aria-valuemax="props.max" aria-orientation="horizontal" :aria-valuenow="rangeValue.stepped" @keydown="keydown($event)"
|
|
tabindex="0" :style="'left: ' + steppedSliderPercentage + '%;'"></span>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
.vl-range-slider {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
width: 200px;
|
|
height: 20px;
|
|
margin: 4px 0;
|
|
}
|
|
|
|
.vl-range-track,
|
|
.vl-range-track-filled {
|
|
display: block;
|
|
height: 4px;
|
|
width: 100%;
|
|
background: #313234;
|
|
border-radius: 99999px;
|
|
}
|
|
|
|
.vl-range-track-filled {
|
|
background: hsl(226, 79%, 47.5%);
|
|
width: 0%;
|
|
}
|
|
|
|
.vl-range-thumb {
|
|
position: absolute;
|
|
display: block;
|
|
height: 20px;
|
|
width: 20px;
|
|
background: #f7f7f7;
|
|
border-radius: 99999px;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.vl-range-thumb:focus {
|
|
outline: 4px solid #b7b7b74a;
|
|
}
|
|
</style> |