225 lines
5.6 KiB
Svelte
225 lines
5.6 KiB
Svelte
<!-- Source: https://svelte.dev/repl/7f0042a186ee4d8e949c46ca663dbe6c?version=3.33.0 -->
|
|
|
|
<script lang="ts">
|
|
import { createEventDispatcher } from "svelte";
|
|
import { kbd } from "@melt-ui/svelte/internal/helpers";
|
|
|
|
import { clamp } from "$lib/util/functions";
|
|
|
|
// Props
|
|
export let min = 0;
|
|
export let max = 100;
|
|
export let value = 0;
|
|
export let ariaLabel: string | undefined = undefined;
|
|
export let marginX = true;
|
|
|
|
/**
|
|
* Value to be displayed next to the slider
|
|
* (current position while dragging, actual value otherwise)
|
|
*/
|
|
export let displayValue = value;
|
|
/** Update value in real time while dragging */
|
|
export let liveUpdate = false;
|
|
|
|
// Node bindings
|
|
let element: HTMLElement;
|
|
|
|
// Internal state
|
|
let elementRect: DOMRect | null;
|
|
let holding = false;
|
|
let keydownAcceleration = 0;
|
|
let accelerationTimer: null | number = null;
|
|
let valueTmp = value;
|
|
let percent = 0;
|
|
|
|
// Dispatch 'change' events
|
|
const dispatch = createEventDispatcher();
|
|
|
|
function resizeSlider() {
|
|
elementRect = element.getBoundingClientRect();
|
|
}
|
|
|
|
// Allows both bind:value and on:change for parent value retrieval
|
|
function setValue(newValue: number) {
|
|
value = newValue;
|
|
dispatch("change", { value });
|
|
}
|
|
|
|
function onTrackEvent(e: Event) {
|
|
// Update value immediately before beginning drag
|
|
updateValueOnEvent(e);
|
|
onDragStart();
|
|
}
|
|
|
|
function onDragStart() {
|
|
holding = true;
|
|
}
|
|
|
|
function onDragEnd() {
|
|
if (holding) {
|
|
setValue(valueTmp);
|
|
holding = false;
|
|
}
|
|
}
|
|
|
|
// Accessible keypress handling
|
|
function onKeyPress(e: KeyboardEvent) {
|
|
// Max out at +/- 10 to value per event (50 events / 5)
|
|
// 100 below is to increase the amount of events required to reach max velocity
|
|
if (keydownAcceleration < 50) keydownAcceleration++;
|
|
let throttled = Math.ceil(keydownAcceleration / 5);
|
|
|
|
if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_RIGHT) {
|
|
if (value + throttled > max || value >= max) {
|
|
setValue(max);
|
|
} else {
|
|
setValue(value + throttled);
|
|
}
|
|
}
|
|
if (e.key === kbd.ARROW_DOWN || e.key === kbd.ARROW_LEFT) {
|
|
if (value - throttled < min || value <= min) {
|
|
setValue(min);
|
|
} else {
|
|
setValue(value - throttled);
|
|
}
|
|
}
|
|
|
|
// Reset acceleration after 100ms of no events
|
|
if (accelerationTimer !== null) {
|
|
clearTimeout(accelerationTimer);
|
|
}
|
|
accelerationTimer = window.setTimeout(() => (keydownAcceleration = 1), 100);
|
|
}
|
|
|
|
function calculateNewValue(clientX: number) {
|
|
if (elementRect === null) return;
|
|
|
|
// Find distance between cursor and element's left cord
|
|
let delta = clientX - elementRect.x;
|
|
|
|
let percent = (delta * 100) / elementRect.width;
|
|
|
|
// Limit percent 0 -> 100
|
|
percent = percent < 0 ? 0 : percent > 100 ? 100 : percent;
|
|
|
|
// Limit value min -> max
|
|
valueTmp = Math.round((percent * (max - min)) / 100 + min);
|
|
|
|
if (liveUpdate) {
|
|
setValue(valueTmp);
|
|
}
|
|
}
|
|
|
|
// Handles both dragging of touch/mouse as well as simple one-off click/touches
|
|
function updateValueOnEvent(e: Event) {
|
|
// touchstart && mousedown are one-off updates, otherwise expect a currentPointer node
|
|
if (!holding && e.type !== "touchstart" && e.type !== "mousedown") return false;
|
|
|
|
if (e.stopPropagation) e.stopPropagation();
|
|
if (e.preventDefault) e.preventDefault();
|
|
|
|
// Get client's x cord either touch or mouse
|
|
const coords =
|
|
e.type === "touchmove" || e.type === "touchstart"
|
|
? (e as TouchEvent).touches[0]
|
|
: (e as MouseEvent);
|
|
|
|
calculateNewValue(coords.clientX);
|
|
}
|
|
|
|
// React to left position of element relative to window
|
|
$: if (element) elementRect = element.getBoundingClientRect();
|
|
|
|
$: displayValue = holding ? valueTmp : value;
|
|
$: percent = ((clamp(displayValue, min, max) - min) * 100) / (max - min);
|
|
</script>
|
|
|
|
<svelte:window
|
|
on:touchmove|nonpassive={updateValueOnEvent}
|
|
on:touchcancel={onDragEnd}
|
|
on:touchend={onDragEnd}
|
|
on:mousemove={updateValueOnEvent}
|
|
on:mouseup={onDragEnd}
|
|
on:resize={resizeSlider}
|
|
/>
|
|
<div class="flex-1" class:mx-2={marginX}>
|
|
<div
|
|
class="slider-wrapper"
|
|
class:holding
|
|
tabindex="0"
|
|
on:keydown={onKeyPress}
|
|
bind:this={element}
|
|
role="slider"
|
|
aria-label={ariaLabel}
|
|
aria-valuemin={min}
|
|
aria-valuemax={max}
|
|
aria-valuenow={value}
|
|
on:mousedown={onTrackEvent}
|
|
on:touchstart|passive={onTrackEvent}
|
|
style={`--slider-transform:${percent}%;`}
|
|
>
|
|
<div class="slider-bg slider-shape">
|
|
<div class="slider-shape overflow-hidden">
|
|
<div class="slider-fill slider-shape" />
|
|
</div>
|
|
<div class="slider-thumb" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style lang="postcss">
|
|
.slider-wrapper {
|
|
min-width: 100%;
|
|
height: 1em;
|
|
position: relative;
|
|
|
|
&:hover,
|
|
&:focus-visible,
|
|
&.holding {
|
|
& .slider-fill {
|
|
@apply bg-primary;
|
|
}
|
|
& .slider-thumb {
|
|
display: block;
|
|
}
|
|
}
|
|
}
|
|
|
|
.slider-bg {
|
|
@apply bg-base-content bg-opacity-40;
|
|
display: flex;
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
.slider-shape {
|
|
width: 100%;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.slider-fill {
|
|
@apply bg-base-content;
|
|
transform: translateX(calc(-100% + var(--slider-transform)));
|
|
}
|
|
|
|
.slider-thumb {
|
|
@apply bg-base-content;
|
|
display: none;
|
|
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
|
|
height: 12px;
|
|
width: 12px;
|
|
|
|
border: 0;
|
|
border-radius: 50%;
|
|
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.5);
|
|
|
|
left: var(--slider-transform);
|
|
margin-left: -6px;
|
|
}
|
|
</style>
|