Files
vueless/components/vlSlider.vue
2023-05-30 15:04:51 -05:00

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>