feat(slider): add pure value/position math helpers
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user