Appearance
Radio Component Code ​
Dependencies
This component requires:
- Vue 3 with Composition API
Full Component Code ​
vue
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
const emit = defineEmits(['update:modelValue', 'change']);
const props = defineProps({
modelValue: {
type: Number,
default: 0,
},
value: {
type: Number,
default: 0,
},
disabled: {
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
step: {
type: Number,
default: 0.1,
},
variant: {
type: String,
default: 'default',
},
label: {
type: String,
default: '',
},
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 100,
},
thumbLabel: {
type: [String, Boolean],
default: false,
validator: (value) => ['always', true, false].includes(value),
},
thumbLabelClasses: {
type: Array,
default: () => [],
},
labelClasses: {
type: Array,
default: () => [],
},
});
const track = ref(null);
const currentValue = ref(props.modelValue ?? props.value);
const isDragging = ref(false);
const isFocused = ref(false);
watch([() => props.modelValue, () => props.value], ([modelVal, val]) => {
currentValue.value = modelVal ?? val;
});
function roundToStep(value, step) {
const stepStr = step.toString();
const decimals = stepStr.includes('.') ? stepStr.split('.')[1].length : 0;
const multiplier = Math.pow(10, decimals);
return Math.round(value * multiplier) / multiplier;
}
const fillPercent = computed(() => {
const percent = ((currentValue.value - props.min) / (props.max - props.min)) * 100;
return Math.min(100, Math.max(0, percent));
});
const displayValue = computed(() => {
const stepStr = props.step.toString();
const decimalPlaces = stepStr.includes('.') ? stepStr.split('.')[1].length : 0;
return currentValue.value.toFixed(decimalPlaces);
});
const showThumbLabel = computed(() => {
if (props.thumbLabel === 'always') return true;
if (props.thumbLabel === true) return isDragging.value || isFocused.value;
return false;
});
const updateValueFromPosition = (clientX) => {
if (!track.value) return;
const rect = track.value.getBoundingClientRect();
let percent = (clientX - rect.left) / rect.width;
percent = Math.max(0, Math.min(1, percent));
let rawValue = props.min + percent * (props.max - props.min);
const steppedValue = roundToStep(Math.round(rawValue / props.step) * props.step, props.step);
if (steppedValue == currentValue.value) return;
currentValue.value = steppedValue;
emit('update:modelValue', steppedValue);
emit('change', steppedValue);
};
const handleMouseMove = (e) => updateValueFromPosition(e.clientX);
const handleTouchMove = (e) => updateValueFromPosition(e.touches[0].clientX);
const stopDrag = () => {
isDragging.value = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('mouseup', stopDrag);
window.removeEventListener('touchend', stopDrag);
};
const startDrag = (e) => {
if (props.disabled || props.readonly) return;
isDragging.value = true;
if (e.type === 'mousedown') {
updateValueFromPosition(e.clientX);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', stopDrag);
} else if (e.type === 'touchstart') {
updateValueFromPosition(e.touches[0].clientX);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', stopDrag);
}
};
const onKeydown = (e) => {
if (props.disabled || props.readonly) return;
let newValue = currentValue.value;
if (e.key === 'ArrowRight') {
newValue = Math.min(props.max, currentValue.value + props.step);
} else if (e.key === 'ArrowLeft') {
newValue = Math.max(props.min, currentValue.value - props.step);
} else {
return;
}
const steppedValue = roundToStep(newValue, props.step);
if (steppedValue !== currentValue.value) {
currentValue.value = steppedValue;
emit('update:modelValue', steppedValue);
emit('change', steppedValue);
}
e.preventDefault(); // prevent scrolling
};
onBeforeUnmount(() => stopDrag());
</script>
<template>
<div class="basic-slider-input-wrapper" :class="{ 'has-label': label }">
<slot name="label">
<label :class="labelClasses" class="slider-label">{{ label }}</label>
</slot>
<div
class="slider-track"
ref="track"
tabindex="0"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
@keydown="onKeydown"
:class="[variant, { disabled, readonly }]"
role="slider"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="currentValue"
:aria-disabled="disabled"
:aria-readonly="readonly"
@focus="isFocused = true"
@blur="isFocused = false"
>
<div class="slider-filled" :style="{ width: fillPercent + '%' }" />
<div
class="slider-thumb"
:style="{ left: fillPercent + '%' }"
@mousedown.stop.prevent="startDrag"
@touchstart.stop.prevent="startDrag"
/>
<div v-show="showThumbLabel" class="thumb-label" :style="{ left: fillPercent + '%' }">
{{ currentValue === 0 || currentValue === 100 ? currentValue : displayValue }}
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.basic-slider-input-wrapper {
width: 100%;
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;
&.has-label {
gap: 1rem;
}
.slider-label {
font-size: 1rem;
}
.slider-track {
position: relative;
height: 0.375rem;
border-radius: 0.25rem;
cursor: pointer;
user-select: none;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 0.25rem rgba(0, 0, 0, 0.1);
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.readonly {
pointer-events: none;
}
.slider-filled {
position: absolute;
height: 100%;
border-radius: 0.25rem 0 0 0.25rem;
}
.slider-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 1rem;
height: 1rem;
border-radius: 50%;
cursor: grab;
z-index: 2;
}
.thumb-label {
position: absolute;
transform: translateX(-50%);
background: #333;
color: white;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
white-space: nowrap;
bottom: 0;
margin-bottom: 1rem;
&::before {
content: '';
position: absolute;
left: calc(50% - 0.25rem);
top: 100%;
width: 0.5rem;
height: 0.5rem;
background: #333;
transform: rotate(45deg) translate(-50%, -50%);
}
}
}
// Variants
.slider-track.default {
background-color: #e0e0e0;
.slider-filled {
background-color: #000;
}
.slider-thumb {
background-color: #000;
}
}
.slider-track.success {
background-color: #e0e0e0;
.slider-filled {
background-color: #22c55e;
}
.slider-thumb {
background-color: #22c55e;
}
}
}
</style>