229 lines
5.0 KiB
Vue
229 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: number
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
format?: (v: number) => string
|
|
disabled?: boolean
|
|
}>(),
|
|
{
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.01,
|
|
format: undefined,
|
|
disabled: false,
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
|
|
|
|
const trackRef = ref<HTMLDivElement | null>(null)
|
|
const dragging = ref(false)
|
|
|
|
const fillPercent = computed(() => {
|
|
const range = props.max - props.min
|
|
if (range <= 0) return 0
|
|
const clamped = Math.max(props.min, Math.min(props.max, props.modelValue))
|
|
return ((clamped - props.min) / range) * 100
|
|
})
|
|
|
|
const formattedValue = computed(() => {
|
|
if (props.format) return props.format(props.modelValue)
|
|
return String(props.modelValue)
|
|
})
|
|
|
|
function snap(value: number): number {
|
|
const s = props.step
|
|
if (s <= 0) return value
|
|
const snapped = Math.round(value / s) * s
|
|
const decimals = (s.toString().split('.')[1] || '').length
|
|
return decimals > 0 ? parseFloat(snapped.toFixed(decimals)) : snapped
|
|
}
|
|
|
|
function clamp(value: number): number {
|
|
return Math.max(props.min, Math.min(props.max, value))
|
|
}
|
|
|
|
function updateFromClientX(clientX: number) {
|
|
const track = trackRef.value
|
|
if (!track) return
|
|
const rect = track.getBoundingClientRect()
|
|
if (rect.width <= 0) return
|
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
|
const raw = props.min + ratio * (props.max - props.min)
|
|
emit('update:modelValue', clamp(snap(raw)))
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
if (props.disabled) return
|
|
if (e.button !== 0) return
|
|
const el = e.currentTarget as HTMLElement
|
|
el.setPointerCapture(e.pointerId)
|
|
el.focus()
|
|
dragging.value = true
|
|
updateFromClientX(e.clientX)
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
const el = e.currentTarget as HTMLElement
|
|
if (!el.hasPointerCapture(e.pointerId)) return
|
|
updateFromClientX(e.clientX)
|
|
}
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
const el = e.currentTarget as HTMLElement
|
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
|
dragging.value = false
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (props.disabled) return
|
|
const big = e.shiftKey ? 10 : 1
|
|
let next = props.modelValue
|
|
switch (e.key) {
|
|
case 'ArrowLeft':
|
|
case 'ArrowDown':
|
|
next -= props.step * big
|
|
break
|
|
case 'ArrowRight':
|
|
case 'ArrowUp':
|
|
next += props.step * big
|
|
break
|
|
case 'Home':
|
|
next = props.min
|
|
break
|
|
case 'End':
|
|
next = props.max
|
|
break
|
|
default:
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
emit('update:modelValue', clamp(snap(next)))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="slider"
|
|
:class="{ disabled, dragging }"
|
|
:tabindex="disabled ? -1 : 0"
|
|
role="slider"
|
|
:aria-valuemin="min"
|
|
:aria-valuemax="max"
|
|
:aria-valuenow="modelValue"
|
|
:aria-disabled="disabled"
|
|
@pointerdown="onPointerDown"
|
|
@pointermove="onPointerMove"
|
|
@pointerup="onPointerUp"
|
|
@pointercancel="onPointerUp"
|
|
@keydown="onKeyDown"
|
|
>
|
|
<div ref="trackRef" class="track">
|
|
<div class="track-bg"></div>
|
|
<div class="track-fill" :style="{ width: fillPercent + '%' }"></div>
|
|
<div class="thumb" :style="{ left: fillPercent + '%' }">
|
|
<div class="thumb-dot"></div>
|
|
</div>
|
|
</div>
|
|
<div class="value">{{ formattedValue }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.slider {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 6px 2px;
|
|
outline: none;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
touch-action: none;
|
|
}
|
|
|
|
.slider.disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.track {
|
|
position: relative;
|
|
flex: 1;
|
|
height: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.track-bg {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
height: 4px;
|
|
background: var(--border);
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.track-fill {
|
|
position: absolute;
|
|
left: 0;
|
|
height: 4px;
|
|
background: var(--accent);
|
|
border-radius: 999px;
|
|
transition: background 140ms var(--ease);
|
|
}
|
|
|
|
.thumb {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 14px;
|
|
height: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.thumb-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: var(--text);
|
|
border: 2px solid var(--accent);
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
|
transition: transform 140ms var(--ease), box-shadow 140ms var(--ease),
|
|
background 140ms var(--ease);
|
|
}
|
|
|
|
.slider:hover:not(.disabled) .thumb-dot {
|
|
transform: scale(1.08);
|
|
}
|
|
|
|
.slider:focus-visible:not(.disabled) .thumb-dot {
|
|
box-shadow: 0 0 0 3px var(--accent-soft-strong), 0 1px 4px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.slider.dragging .thumb-dot {
|
|
transform: scale(1.15);
|
|
background: var(--accent);
|
|
}
|
|
|
|
.value {
|
|
min-width: 44px;
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
font-size: 12px;
|
|
color: var(--text-dim-strong);
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.slider:hover:not(.disabled) .value {
|
|
color: var(--text);
|
|
}
|
|
</style>
|