From 4756682863f4a7f76e90bfc9a073702c0fecd303 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 10:50:46 +0300 Subject: [PATCH 1/7] feat(slider): add pure value/position math helpers --- src/shared/ui/Slider/slider-math.test.ts | 55 ++++++++++++++++++++++ src/shared/ui/Slider/slider-math.ts | 59 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/shared/ui/Slider/slider-math.test.ts create mode 100644 src/shared/ui/Slider/slider-math.ts diff --git a/src/shared/ui/Slider/slider-math.test.ts b/src/shared/ui/Slider/slider-math.test.ts new file mode 100644 index 0000000..3e7da60 --- /dev/null +++ b/src/shared/ui/Slider/slider-math.test.ts @@ -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); + }); +}); diff --git a/src/shared/ui/Slider/slider-math.ts b/src/shared/ui/Slider/slider-math.ts new file mode 100644 index 0000000..43b8a08 --- /dev/null +++ b/src/shared/ui/Slider/slider-math.ts @@ -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); +} From 9d6220d2ecc55a063dcb4ae0aa86a0cdbbfa11ff Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 2 Jun 2026 10:54:54 +0300 Subject: [PATCH 2/7] feat(slider): reimplement natively without bits-ui --- src/shared/ui/Slider/Slider.svelte | 237 +++++++++++++++++++++-------- 1 file changed, 177 insertions(+), 60 deletions(-) diff --git a/src/shared/ui/Slider/Slider.svelte b/src/shared/ui/Slider/Slider.svelte index 081dd29..068e022 100644 --- a/src/shared/ui/Slider/Slider.svelte +++ b/src/shared/ui/Slider/Slider.svelte @@ -1,13 +1,16 @@ { }); let trackEl: HTMLElement | undefined = $state(); +let thumbEl: HTMLElement | undefined = $state(); let dragging = $state(false); /** @@ -131,6 +132,7 @@ function handlePointerDown(event: PointerEvent): void { return; } dragging = true; + thumbEl?.focus(); (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId); seek(event); } @@ -237,6 +239,7 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand { 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;