Compare commits
7 Commits
67af3d946a
...
ae2d0e3c2f
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2d0e3c2f | |||
| 3f5151efa0 | |||
| 19d9b07c55 | |||
| 1209358d40 | |||
| d7decd7a00 | |||
| 9d6220d2ec | |||
| 4756682863 |
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Styled bits-ui slider component with red accent (#ff3b30). Thumb is a 45° rotated square with hover/active scale animations.',
|
'Single-value slider (native, no bits-ui) with brand accent. Diamond thumb (45° rotated square) with hover/active scale. Supports pointer drag, click-to-seek, touch, and keyboard (arrows, Home/End, PageUp/Down).',
|
||||||
},
|
},
|
||||||
story: { inline: false }, // Render stories in iframe for state isolation
|
story: { inline: false }, // Render stories in iframe for state isolation
|
||||||
},
|
},
|
||||||
@@ -39,8 +39,6 @@ const { Story } = defineMeta({
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
let value = $state(50);
|
let value = $state(50);
|
||||||
let valueLow = $state(25);
|
|
||||||
let valueHigh = $state(75);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
|
|||||||
@@ -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,122 @@ 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 = $state();
|
||||||
|
let thumbEl: HTMLElement | undefined = $state();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep an externally-supplied value normalized to the step grid and range.
|
||||||
|
* Mirrors the bits-ui primitive's behavior so out-of-range or off-grid
|
||||||
|
* props don't desync the thumb position from aria-valuenow / the label.
|
||||||
|
* Converges in one pass: once snapped, the value equals its own snap.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const normalized = snapToStep(value, { min, max, step });
|
||||||
|
if (normalized !== value) {
|
||||||
|
value = normalized;
|
||||||
|
onValueChange?.(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
thumbEl?.focus();
|
||||||
|
(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,81 +208,101 @@ 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
|
||||||
|
class="
|
||||||
|
bg-neutral-200 dark:bg-neutral-800
|
||||||
|
relative grow w-px overflow-visible
|
||||||
|
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
||||||
|
transition-colors
|
||||||
|
"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="
|
class="absolute bottom-0 left-0 bg-brand w-full"
|
||||||
bg-neutral-200 dark:bg-neutral-800
|
style="height: {percent}%"
|
||||||
relative grow w-px overflow-visible
|
></span>
|
||||||
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
</span>
|
||||||
transition-colors
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<Slider.Range class="absolute bg-brand w-full" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#each thumbItems as thumb (thumb)}
|
<span
|
||||||
<Slider.Thumb
|
role="slider"
|
||||||
index={thumb.index}
|
bind:this={thumbEl}
|
||||||
class={thumbClasses}
|
tabindex={disabled ? -1 : 0}
|
||||||
aria-label="Value"
|
aria-label="Value"
|
||||||
/>
|
aria-orientation="vertical"
|
||||||
{/each}
|
aria-valuemin={min}
|
||||||
{/snippet}
|
aria-valuemax={max}
|
||||||
</Slider.Root>
|
aria-valuenow={value}
|
||||||
|
aria-valuetext={String(format(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
|
||||||
|
class="
|
||||||
|
bg-neutral-200 dark:bg-neutral-800
|
||||||
|
relative grow h-px overflow-visible
|
||||||
|
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
||||||
|
transition-colors
|
||||||
|
"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="
|
class="absolute top-0 left-0 bg-brand h-full"
|
||||||
bg-neutral-200 dark:bg-neutral-800
|
style="width: {percent}%"
|
||||||
relative grow h-px overflow-visible
|
></span>
|
||||||
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
</span>
|
||||||
transition-colors
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<Slider.Range class="absolute bg-brand h-full" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#each thumbItems as thumb (thumb)}
|
<span
|
||||||
<Slider.Thumb
|
role="slider"
|
||||||
index={thumb.index}
|
bind:this={thumbEl}
|
||||||
class={thumbClasses}
|
tabindex={disabled ? -1 : 0}
|
||||||
aria-label="Value"
|
aria-label="Value"
|
||||||
/>
|
aria-orientation="horizontal"
|
||||||
{/each}
|
aria-valuemin={min}
|
||||||
{/snippet}
|
aria-valuemax={max}
|
||||||
</Slider.Root>
|
aria-valuenow={value}
|
||||||
|
aria-valuetext={String(format(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">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
@@ -60,3 +61,109 @@ describe('Slider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Keyboard', () => {
|
||||||
|
it('increments by step on ArrowRight / ArrowUp', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(Slider, { value: 50, step: 5, onValueChange });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '55');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrements by step on ArrowLeft / ArrowDown', async () => {
|
||||||
|
render(Slider, { value: 50, step: 5 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'ArrowDown' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to min on Home and max on End', async () => {
|
||||||
|
render(Slider, { value: 50, min: 10, max: 90 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'Home' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '10');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'End' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '90');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves by step*10 on PageUp / PageDown', async () => {
|
||||||
|
render(Slider, { value: 50, step: 2 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'PageUp' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '70');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'PageDown' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps at the bounds', async () => {
|
||||||
|
render(Slider, { value: 98, max: 100, step: 5 });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'End' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when disabled', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(Slider, { value: 50, disabled: true, onValueChange });
|
||||||
|
const thumb = screen.getByRole('slider');
|
||||||
|
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
||||||
|
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
expect(onValueChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pointer', () => {
|
||||||
|
/**
|
||||||
|
* Force a deterministic track rect since jsdom has no layout.
|
||||||
|
*/
|
||||||
|
function mockTrackRect(container: HTMLElement) {
|
||||||
|
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||||
|
track.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('seeks to the clicked position (click-to-seek)', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100, onValueChange });
|
||||||
|
const track = mockTrackRect(container);
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates while dragging after pointerdown', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
|
||||||
|
const track = mockTrackRect(container);
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 50, clientY: 10, pointerId: 1 });
|
||||||
|
await fireEvent.pointerMove(track, { clientX: 150, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores pointer when disabled', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, disabled: true });
|
||||||
|
const track = mockTrackRect(container);
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the thumb on pointerdown so arrow keys work immediately', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
|
||||||
|
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||||
|
track.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toBe(document.activeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a vertical drag with the inverted axis (bottom→min, top→max)', async () => {
|
||||||
|
const { container } = render(Slider, { value: 0, min: 0, max: 100, orientation: 'vertical' });
|
||||||
|
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||||
|
track.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, right: 20, top: 0, bottom: 200, width: 20, height: 200 }) as DOMRect;
|
||||||
|
await fireEvent.pointerDown(track, { clientX: 10, clientY: 50, pointerId: 1 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
pointerToValue,
|
||||||
|
snapToStep,
|
||||||
|
} from './slider-math';
|
||||||
|
|
||||||
|
describe('snapToStep', () => {
|
||||||
|
it('snaps a raw value to the nearest step on the grid', () => {
|
||||||
|
expect(snapToStep(53, { min: 0, max: 100, step: 10 })).toBe(50);
|
||||||
|
expect(snapToStep(56, { min: 0, max: 100, step: 10 })).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps below min and above max', () => {
|
||||||
|
expect(snapToStep(-20, { min: 0, max: 100, step: 1 })).toBe(0);
|
||||||
|
expect(snapToStep(200, { min: 0, max: 100, step: 1 })).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a non-zero min when snapping', () => {
|
||||||
|
expect(snapToStep(13, { min: 10, max: 90, step: 5 })).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves fractional step precision', () => {
|
||||||
|
expect(snapToStep(1.34, { min: 0, max: 2, step: 0.05 })).toBe(1.35);
|
||||||
|
expect(snapToStep(0.31, { min: 0, max: 1, step: 0.1 })).toBe(0.3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pointerToValue', () => {
|
||||||
|
const rect = { left: 100, right: 300, top: 50, bottom: 250, width: 200, height: 200 } as DOMRect;
|
||||||
|
|
||||||
|
it('maps horizontal pointer position left→min, right→max', () => {
|
||||||
|
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
|
||||||
|
expect(pointerToValue({ clientX: 100, clientY: 0 }, rect, opts)).toBe(0);
|
||||||
|
expect(pointerToValue({ clientX: 200, clientY: 0 }, rect, opts)).toBe(50);
|
||||||
|
expect(pointerToValue({ clientX: 300, clientY: 0 }, rect, opts)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inverts vertical: bottom→min, top→max', () => {
|
||||||
|
const opts = { min: 0, max: 100, step: 1, orientation: 'vertical' as const };
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 250 }, rect, opts)).toBe(0);
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 150 }, rect, opts)).toBe(50);
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 50 }, rect, opts)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps when pointer is outside the track', () => {
|
||||||
|
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 0 }, rect, opts)).toBe(0);
|
||||||
|
expect(pointerToValue({ clientX: 9999, clientY: 0 }, rect, opts)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns min for a zero-size track without NaN', () => {
|
||||||
|
const zero = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 } as DOMRect;
|
||||||
|
const opts = { min: 5, max: 95, step: 1, orientation: 'horizontal' as const };
|
||||||
|
expect(pointerToValue({ clientX: 0, clientY: 0 }, zero, opts)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
clampNumber,
|
||||||
|
roundToStepPrecision,
|
||||||
|
} from '$shared/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geometry/range options shared by the math helpers.
|
||||||
|
*/
|
||||||
|
type SliderMathOpts = {
|
||||||
|
/**
|
||||||
|
* Minimum value (inclusive)
|
||||||
|
*/
|
||||||
|
min: number;
|
||||||
|
/**
|
||||||
|
* Maximum value (inclusive)
|
||||||
|
*/
|
||||||
|
max: number;
|
||||||
|
/**
|
||||||
|
* Step increment
|
||||||
|
*/
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap a raw value onto the step grid, then clamp to [min, max].
|
||||||
|
*
|
||||||
|
* Snapping is anchored to `min` so non-zero ranges land on valid stops.
|
||||||
|
* `roundToStepPrecision` removes IEEE-754 drift from fractional steps.
|
||||||
|
*/
|
||||||
|
export function snapToStep(raw: number, { min, max, step }: SliderMathOpts): number {
|
||||||
|
if (step <= 0) {
|
||||||
|
return clampNumber(raw, min, max);
|
||||||
|
}
|
||||||
|
const snapped = min + Math.round((raw - min) / step) * step;
|
||||||
|
return clampNumber(roundToStepPrecision(snapped, step), min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a pointer coordinate into a slider value.
|
||||||
|
*
|
||||||
|
* Horizontal maps left→min, right→max. Vertical is inverted so that
|
||||||
|
* up→max, matching natural slider expectations. The DOMRect is passed in
|
||||||
|
* to keep this pure and unit-testable without layout.
|
||||||
|
*/
|
||||||
|
export function pointerToValue(
|
||||||
|
point: { clientX: number; clientY: number },
|
||||||
|
rect: DOMRect,
|
||||||
|
opts: SliderMathOpts & { orientation: 'horizontal' | 'vertical' },
|
||||||
|
): number {
|
||||||
|
const { min, max, orientation } = opts;
|
||||||
|
const size = orientation === 'vertical' ? rect.height : rect.width;
|
||||||
|
if (size <= 0) {
|
||||||
|
return snapToStep(min, opts);
|
||||||
|
}
|
||||||
|
const ratio = orientation === 'vertical'
|
||||||
|
? (rect.bottom - point.clientY) / size
|
||||||
|
: (point.clientX - rect.left) / size;
|
||||||
|
return snapToStep(min + clampNumber(ratio, 0, 1) * (max - min), opts);
|
||||||
|
}
|
||||||
@@ -44,3 +44,20 @@ Object.defineProperty(window, 'localStorage', {
|
|||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks PointerEvent; back it with MouseEvent so clientX/clientY survive.
|
||||||
|
if (typeof PointerEvent === 'undefined') {
|
||||||
|
class PointerEventPolyfill extends MouseEvent {
|
||||||
|
pointerId: number;
|
||||||
|
constructor(type: string, params: PointerEventInit = {}) {
|
||||||
|
super(type, params);
|
||||||
|
this.pointerId = params.pointerId ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @ts-expect-error assigning polyfill to the global scope
|
||||||
|
global.PointerEvent = PointerEventPolyfill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom lacks pointer capture
|
||||||
|
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
|
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user