initial commit
This commit is contained in:
227
components/vlSlider.vue
Normal file
227
components/vlSlider.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user