Feature/slider #47

Merged
ilia merged 7 commits from feature/slider into main 2026-06-02 12:10:43 +00:00
Showing only changes of commit 9d6220d2ec - Show all commits
+159 -42
View File
@@ -1,13 +1,16 @@
<!-- <!--
Component: Slider Component: Slider
Single-value slider using bits-ui Slider primitive. Single-value slider built on a native role="slider" element (no bits-ui).
Supports pointer drag, click-to-seek, touch, and full keyboard nav.
Swiss design: 1px track, diamond thumb (rotate-45), brand accent. Swiss design: 1px track, diamond thumb (rotate-45), brand accent.
--> -->
<script lang="ts"> <script lang="ts">
import { import {
type Orientation, pointerToValue,
Slider, snapToStep,
} from 'bits-ui'; } from './slider-math';
type Orientation = 'horizontal' | 'vertical';
interface Props { interface Props {
/** /**
@@ -67,8 +70,106 @@ let {
class: className, class: className,
}: Props = $props(); }: Props = $props();
/**
* PageUp/PageDown move by this multiple of `step`.
*/
const LARGE_STEP_MULTIPLIER = 10;
const isVertical = $derived(orientation === 'vertical'); const isVertical = $derived(orientation === 'vertical');
/**
* Thumb/range offset as a clamped percentage of the track.
*/
const percent = $derived.by(() => {
if (max <= min) {
return 0;
}
return Math.min(Math.max(((value - min) / (max - min)) * 100, 0), 100);
});
let trackEl: HTMLElement | undefined;
let dragging = $state(false);
/**
* Apply a candidate value: snap, clamp, store, and notify only on change.
*/
function commit(raw: number): void {
const next = snapToStep(raw, { min, max, step });
if (next !== value) {
value = next;
onValueChange?.(next);
}
}
/**
* Resolve a pointer event to a value using the live track rect.
*/
function seek(event: PointerEvent): void {
if (!trackEl) {
return;
}
const rect = trackEl.getBoundingClientRect();
commit(pointerToValue(event, rect, { min, max, step, orientation }));
}
function handlePointerDown(event: PointerEvent): void {
if (disabled) {
return;
}
dragging = true;
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
seek(event);
}
function handlePointerMove(event: PointerEvent): void {
if (!dragging || disabled) {
return;
}
seek(event);
}
function handlePointerUp(event: PointerEvent): void {
if (!dragging) {
return;
}
dragging = false;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
}
function handleKeyDown(event: KeyboardEvent): void {
if (disabled) {
return;
}
const large = step * LARGE_STEP_MULTIPLIER;
let next: number | undefined;
switch (event.key) {
case 'ArrowRight':
case 'ArrowUp':
next = value + step;
break;
case 'ArrowLeft':
case 'ArrowDown':
next = value - step;
break;
case 'PageUp':
next = value + large;
break;
case 'PageDown':
next = value - large;
break;
case 'Home':
next = min;
break;
case 'End':
next = max;
break;
default:
return;
}
event.preventDefault();
commit(next);
}
const labelClasses = `font-mono text-2xs tabular-nums shrink-0 const labelClasses = `font-mono text-2xs tabular-nums shrink-0
text-subtle text-subtle
group-hover:text-neutral-700 dark:group-hover:text-neutral-300 group-hover:text-neutral-700 dark:group-hover:text-neutral-300
@@ -91,22 +192,21 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
{format(value)} {format(value)}
</span> </span>
<Slider.Root <div
type="single" bind:this={trackEl}
orientation="vertical" role="presentation"
bind:value onpointerdown={handlePointerDown}
{min} onpointermove={handlePointerMove}
{max} onpointerup={handlePointerUp}
{step} onpointercancel={handlePointerUp}
{disabled}
onValueChange={(v => onValueChange?.(v))}
class=" class="
relative flex flex-col items-center select-none touch-none relative flex flex-col items-center select-none touch-none
w-5 h-full grow cursor-row-resize w-5 h-full grow cursor-row-resize
disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed
" "
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
> >
{#snippet children({ thumbItems })}
<span <span
class=" class="
bg-neutral-200 dark:bg-neutral-800 bg-neutral-200 dark:bg-neutral-800
@@ -115,37 +215,45 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
transition-colors transition-colors
" "
> >
<Slider.Range class="absolute bg-brand w-full" /> <span
class="absolute bottom-0 left-0 bg-brand w-full"
style="height: {percent}%"
></span>
</span> </span>
{#each thumbItems as thumb (thumb)} <span
<Slider.Thumb role="slider"
index={thumb.index} tabindex={disabled ? -1 : 0}
class={thumbClasses}
aria-label="Value" aria-label="Value"
/> aria-orientation="vertical"
{/each} aria-valuemin={min}
{/snippet} aria-valuemax={max}
</Slider.Root> aria-valuenow={value}
aria-disabled={disabled ? 'true' : undefined}
data-active={dragging ? '' : undefined}
onkeydown={handleKeyDown}
class="{thumbClasses} absolute left-1/2 -translate-x-1/2 translate-y-1/2"
style="bottom: {percent}%"
></span>
</div>
</div> </div>
{:else} {:else}
<div class="flex items-center gap-4 group w-full {className ?? ''}"> <div class="flex items-center gap-4 group w-full {className ?? ''}">
<Slider.Root <div
type="single" bind:this={trackEl}
orientation="horizontal" role="presentation"
bind:value onpointerdown={handlePointerDown}
{min} onpointermove={handlePointerMove}
{max} onpointerup={handlePointerUp}
{step} onpointercancel={handlePointerUp}
{disabled}
onValueChange={(v => onValueChange?.(v))}
class=" class="
relative flex items-center select-none touch-none relative flex items-center select-none touch-none
w-full h-5 cursor-col-resize w-full h-5 cursor-col-resize
disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed
" "
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
> >
{#snippet children({ thumbItems })}
<span <span
class=" class="
bg-neutral-200 dark:bg-neutral-800 bg-neutral-200 dark:bg-neutral-800
@@ -154,18 +262,27 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
transition-colors transition-colors
" "
> >
<Slider.Range class="absolute bg-brand h-full" /> <span
class="absolute top-0 left-0 bg-brand h-full"
style="width: {percent}%"
></span>
</span> </span>
{#each thumbItems as thumb (thumb)} <span
<Slider.Thumb role="slider"
index={thumb.index} tabindex={disabled ? -1 : 0}
class={thumbClasses}
aria-label="Value" aria-label="Value"
/> aria-orientation="horizontal"
{/each} aria-valuemin={min}
{/snippet} aria-valuemax={max}
</Slider.Root> aria-valuenow={value}
aria-disabled={disabled ? 'true' : undefined}
data-active={dragging ? '' : undefined}
onkeydown={handleKeyDown}
class="{thumbClasses} absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
style="left: {percent}%"
></span>
</div>
<!-- Label: right of slider --> <!-- Label: right of slider -->
<span class="{labelClasses} w-12 text-right"> <span class="{labelClasses} w-12 text-right">