src/components/input-and-actions/VueSlider/VueSlider.vue
<template>
<div
role="slider"
:aria-valuemax="max"
:aria-valuemin="min"
:aria-valuenow="currentMin"
:class="[$style.vueSlider, disabled && $style.disabled]"
>
<vue-columns space="0" :class="$style.slider">
<vue-column align-y="center" :can-grow="false">
<vue-text :class="[$style.label, $style.min]">{{ formatValue(currentMin) }}</vue-text>
</vue-column>
<vue-column align-y="center" width="10/12" :data-testid="id" @mousedown="moveStart" @touchstart="moveStart">
<div ref="sliderRef" :class="$style.track">
<div :class="$style.progress" :style="{ width: progressWidth, marginLeft: progressLeft }" />
<button
v-if="isMultiRange"
ref="leftHandleRef"
:data-testid="`handle-${id}-0`"
:class="$style.handle"
:style="{ left: handleLeftPosition }"
:disabled="disabled"
:aria-disabled="disabled"
tabindex="0"
type="button"
aria-label="left handle"
@focus.prevent.stop="currentSlider = 0"
@keydown.left.right.prevent.stop="onKeyDown"
@keyup.left.right.prevent.stop="onKeyUp"
@focusout.prevent.stop="currentSlider = null"
/>
<button
ref="rightHandleRef"
:data-testid="`handle-${id}-1`"
:class="$style.handle"
:style="{ left: handleRightPosition }"
:disabled="disabled"
:aria-disabled="disabled"
tabindex="0"
type="button"
aria-label="right handle"
@focus.prevent.stop="currentSlider = 1"
@keydown.left.right.prevent.stop="onKeyDown"
@keyup.left.right.prevent.stop="onKeyUp"
@focusout.prevent.stop="currentSlider = null"
/>
</div>
</vue-column>
<vue-column align-y="center" :can-grow="false">
<vue-text :class="[$style.label, $style.max]">{{ formatValue(currentMax) }}</vue-text>
</vue-column>
</vue-columns>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, useCssModule, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { IAlgorithm, linear } from './algorithms';
import { getDomRef } from '~/composables/get-dom-ref';
import VueText from '~/components/typography/VueText/VueText.vue';
import VueColumns from '~/components/layout/VueColumns/VueColumns.vue';
import VueColumn from '~/components/layout/VueColumns/VueColumn/VueColumn.vue';
const algorithm: IAlgorithm = linear;
// Interface
interface SliderProps {
id: string;
min: number;
max: number;
modelValue: [number, number?];
formatValue?: (value: number) => number;
keyboardStepInterval?: number;
disabled?: boolean;
}
interface SliderEmits {
(e: 'update:modelValue', value: [number, number?]): void;
}
const props = withDefaults(defineProps<SliderProps>(), {
formatValue: (value: number) => {
return value;
},
keyboardStepInterval: 5,
});
const emit = defineEmits<SliderEmits>();
// Deps
const $style = useCssModule();
// DOM refs
const sliderRef = getDomRef<HTMLDivElement>(null);
const leftHandleRef = getDomRef<HTMLButtonElement>(null);
const rightHandleRef = getDomRef<HTMLButtonElement>(null);
// computed
const range = computed<number[]>(() => props.modelValue as number[]);
const isMultiRange = computed(() => props.modelValue.length > 1);
const handleLeftPosition = computed(() => `${algorithm.getPosition(currentMin.value, props.min, props.max)}%`);
const handleRightPosition = computed(() => `${algorithm.getPosition(currentMax.value, props.min, props.max)}%`);
const progressLeft = computed(() => {
if (isMultiRange.value) {
return `${algorithm.getPosition(currentMin.value, props.min, props.max)}%`;
} else {
return '0';
}
});
const progressWidth = computed(() => {
if (isMultiRange.value) {
return `${parseFloat(handleRightPosition.value) - parseFloat(handleLeftPosition.value)}%`;
} else {
return `${parseFloat(handleRightPosition.value)}%`;
}
});
// refs
const handleSize = ref(0);
const sliderBox = ref<Partial<DOMRect>>({
bottom: 0,
left: 0,
top: 0,
height: 0,
right: 0,
x: 0,
y: 0,
width: 0,
});
const currentSlider = ref<number>(-1);
const currentMin = ref(0);
const currentMax = ref(0);
// private functions
const getClosestHandle = (percentageDiff: number) => {
if (isMultiRange.value === false) {
return 1;
}
const handlePos: number[] = [parseFloat(handleLeftPosition.value), parseFloat(handleRightPosition.value)];
const startIndex = 1;
return handlePos.reduce((closestIdx, _, idx) => {
const challenger = Math.abs(handlePos[idx] - percentageDiff);
const current = Math.abs(handlePos[closestIdx] - percentageDiff);
return challenger < current ? idx : closestIdx;
}, startIndex);
};
const calculatePercentageDiff = (e: any) => {
/* c8 ignore start */
const positionX: number =
e.changedTouches && e.changedTouches.length > 0 ? e.changedTouches[e.changedTouches.length - 1].clientX : e.clientX;
/* c8 ignore end */
return ((positionX - sliderBox.value.left) / sliderBox.value.width) * 100;
};
const bindEvents = () => {
document.addEventListener('touchend', moveEnd, { passive: false });
document.addEventListener('mouseup', moveEnd);
document.addEventListener('mouseleave', moveEnd);
document.addEventListener('touchmove', moving, { passive: false });
document.addEventListener('mousemove', moving);
};
const unbindEvents = () => {
document.removeEventListener('touchend', moveEnd);
document.removeEventListener('mouseup', moveEnd);
document.removeEventListener('mouseleave', moveEnd);
document.removeEventListener('touchmove', moving);
document.removeEventListener('mousemove', moving);
};
const emitChange = () => {
if (isMultiRange.value) {
emit('update:modelValue', [currentMin.value, currentMax.value]);
} else {
emit('update:modelValue', [currentMax.value]);
}
};
const refresh = () => (sliderBox.value = sliderRef.value.getBoundingClientRect());
const updateRangeIfValid = (newValue: number) => {
if (newValue < props.min) {
currentMin.value = props.min;
return;
} else if (newValue > props.max) {
currentMax.value = props.max;
return;
}
if (currentSlider.value === 0 && newValue > currentMax.value) {
newValue = currentMax.value;
} else if (currentSlider.value === 1 && newValue <= currentMin.value) {
newValue = currentMin.value;
}
if (currentSlider.value === 0) {
currentMin.value = newValue;
} else {
currentMax.value = newValue;
}
};
// event handler
const moveStart = (e: any) => {
if (props.disabled) {
return;
}
/* c8 ignore start */
if (e.changedTouches && e.changedTouches.length > 1) {
return;
}
// I don't know exactly why this is needed but without the timeout
// currentSlider.value can become null after assigning the events
// that means checking for null value in moving and moveEnd
// this solution seems the easiest workaround without knowing the details fo this issue
setTimeout(() => {
currentSlider.value = getClosestHandle(calculatePercentageDiff(e));
bindEvents();
moving(e);
}, 1);
/* c8 ignore end */
};
const moving = (e: any) => {
updateRangeIfValid(algorithm.getValue(calculatePercentageDiff(e), props.min, props.max));
};
const moveEnd = () => {
unbindEvents();
emitChange();
};
const onKeyDown = (e: any) => {
let newValue: number = currentSlider.value === 0 ? currentMin.value : currentMax.value;
if (e.code === 'ArrowLeft') {
newValue = newValue - props.keyboardStepInterval;
} else {
newValue = newValue + props.keyboardStepInterval;
}
updateRangeIfValid(newValue);
};
const onKeyUp = () => {
emitChange();
};
// watcher
watch(
range,
() => {
currentMin.value = isMultiRange.value ? range.value[0] : props.min;
currentMax.value = isMultiRange.value ? range.value[1] : range.value[0];
},
{ immediate: true },
);
onMounted(() => {
handleSize.value = rightHandleRef.value.clientWidth;
useEventListener(window, 'resize', refresh);
refresh();
});
</script>
<style lang="scss" module>
@import 'assets/_design-system.scss';
.vueSlider {
user-select: none;
height: $slider-height;
display: flex;
flex-direction: column;
justify-content: center;
gap: $space-8;
&.disabled {
cursor: not-allowed;
.slider {
.track {
opacity: $slider-disabled-opacity;
.handle {
cursor: not-allowed;
&:hover {
width: $slider-handle-size;
height: $slider-handle-size;
}
}
}
}
}
.slider {
position: relative;
.label {
width: $slider-label-width;
&.min {
text-align: left;
}
&.max {
text-align: right;
}
}
.track {
position: relative;
width: 100%;
height: $slider-track-height;
background-color: $slider-track-bg;
border-radius: $slider-track-border-radius;
.progress {
height: $slider-track-height;
background: $slider-progress-bg;
border-radius: $slider-progress-border-radius;
}
.handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: $slider-handle-size;
height: $slider-handle-size;
padding: 0;
cursor: pointer;
user-select: none;
border-radius: $slider-handle-border-radius;
background-color: $slider-handle-bg;
border: none;
outline: none;
&:hover {
width: $slider-handle-size-hover;
height: $slider-handle-size-hover;
}
&:focus {
box-shadow: $slider-handle-active-shadow;
}
}
}
}
}
</style>