157 lines
4.0 KiB
Svelte
157 lines
4.0 KiB
Svelte
<script lang="ts">
|
|
interface Props {
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
value: number;
|
|
labels?: { value: number; label: string }[];
|
|
onChange: (value: number) => void;
|
|
}
|
|
|
|
let { min, max, step, value, labels, onChange }: Props = $props();
|
|
|
|
let trackRef: HTMLDivElement | undefined = $state();
|
|
let dragging = $state(false);
|
|
|
|
let fillPercent = $derived(((value - min) / (max - min)) * 100);
|
|
|
|
let currentLabel = $derived.by(() => {
|
|
if (!labels) return null;
|
|
let closest = labels[0];
|
|
let dist = Math.abs(value - closest.value);
|
|
for (const l of labels) {
|
|
const d = Math.abs(value - l.value);
|
|
if (d < dist) { closest = l; dist = d; }
|
|
}
|
|
return closest?.label ?? null;
|
|
});
|
|
|
|
function valueFromEvent(e: MouseEvent | PointerEvent) {
|
|
if (!trackRef) return value;
|
|
const rect = trackRef.getBoundingClientRect();
|
|
let pct = (e.clientX - rect.left) / rect.width;
|
|
pct = Math.max(0, Math.min(1, pct));
|
|
let raw = min + pct * (max - min);
|
|
|
|
if (labels && labels.length > 0) {
|
|
let closest = labels[0].value;
|
|
let dist = Math.abs(raw - closest);
|
|
for (const l of labels) {
|
|
const d = Math.abs(raw - l.value);
|
|
if (d < dist) { closest = l.value; dist = d; }
|
|
}
|
|
const snapThreshold = (max - min) * 0.08;
|
|
if (dist < snapThreshold) raw = closest;
|
|
}
|
|
|
|
raw = Math.round(raw / step) * step;
|
|
return Math.max(min, Math.min(max, raw));
|
|
}
|
|
|
|
function handlePointerDown(e: PointerEvent) {
|
|
dragging = true;
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
onChange(valueFromEvent(e));
|
|
}
|
|
|
|
function handlePointerMove(e: PointerEvent) {
|
|
if (!dragging) return;
|
|
onChange(valueFromEvent(e));
|
|
}
|
|
|
|
function handlePointerUp() {
|
|
dragging = false;
|
|
}
|
|
</script>
|
|
|
|
<div class="slider-wrap">
|
|
{#if currentLabel}
|
|
<div class="slider-value">{currentLabel}</div>
|
|
{/if}
|
|
<div
|
|
bind:this={trackRef}
|
|
class="slider-track"
|
|
role="slider"
|
|
aria-valuemin={min}
|
|
aria-valuemax={max}
|
|
aria-valuenow={value}
|
|
tabindex="0"
|
|
onpointerdown={handlePointerDown}
|
|
onpointermove={handlePointerMove}
|
|
onpointerup={handlePointerUp}
|
|
>
|
|
<div class="slider-fill" style="width: {fillPercent}%"></div>
|
|
<div
|
|
class="slider-thumb"
|
|
style="left: {fillPercent}%; transition: {dragging ? 'none' : 'left var(--transition-fast)'};"
|
|
></div>
|
|
</div>
|
|
{#if labels}
|
|
<div class="slider-labels">
|
|
{#each labels as stop, i}
|
|
{@const pct = ((stop.value - min) / (max - min)) * 100}
|
|
<span
|
|
class="slider-label"
|
|
style="left: {pct}%; {i === 0 ? 'transform: none; text-align: left' : i === labels.length - 1 ? 'transform: translateX(-100%); text-align: right' : 'transform: translateX(-50%); text-align: center'}"
|
|
>
|
|
{stop.label}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.slider-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.slider-value {
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-xs);
|
|
font-weight: 600;
|
|
color: var(--color-accent-compress);
|
|
text-align: right;
|
|
}
|
|
.slider-track {
|
|
position: relative;
|
|
height: 8px;
|
|
border-radius: 4px;
|
|
background: var(--color-bg-elevated);
|
|
border: 1px solid var(--color-border);
|
|
cursor: pointer;
|
|
}
|
|
.slider-fill {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100%;
|
|
border-radius: 4px;
|
|
background: var(--color-accent-compress);
|
|
}
|
|
.slider-thumb {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: white;
|
|
border: 2px solid var(--color-accent-compress);
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
|
}
|
|
.slider-labels {
|
|
position: relative;
|
|
height: 16px;
|
|
margin-top: 2px;
|
|
}
|
|
.slider-label {
|
|
position: absolute;
|
|
font-family: var(--font-body);
|
|
font-size: 10px;
|
|
color: var(--color-text-disabled);
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|