This commit is contained in:
th committed 2026-04-19 17:53:36 +02:00
commit c6863f2bfc
34 files changed
+6909

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
<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>