feat(slider): reimplement natively without bits-ui
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user