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); +}