1 parent
de99931c04
commit
53b7be3c6f
4 files changed
+287
-31
No files matched your search
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldCheckInfiniteScrollAfterScroll } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import { resolveDataGridWheelScroll, type DataGridWheelMetrics } from "@/lib/dataGrid/dataGridWheel";
|
||||
|
||||
const metrics: DataGridWheelMetrics = {
|
||||
scrollTop: 100,
|
||||
scrollLeft: 200,
|
||||
scrollHeight: 800,
|
||||
scrollWidth: 1000,
|
||||
clientHeight: 300,
|
||||
clientWidth: 400,
|
||||
};
|
||||
|
||||
function resolveWheel(overrides: Partial<Parameters<typeof resolveDataGridWheelScroll>[0]> = {}) {
|
||||
return resolveDataGridWheelScroll({
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
deltaMode: 0,
|
||||
lineSize: 28,
|
||||
metrics,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("data grid wheel scrolling", () => {
|
||||
it.each([
|
||||
{ deltaX: 120, nextScrollLeft: 320 },
|
||||
{ deltaX: -120, nextScrollLeft: 80 },
|
||||
])("keeps pixel deltaX at 1:1 with Canvas acceleration ($deltaX)", ({ deltaX, nextScrollLeft }) => {
|
||||
expect(resolveWheel({ deltaX, accelerationFactor: 1.5 })).toEqual({
|
||||
scrollDeltaX: deltaX,
|
||||
scrollDeltaY: 0,
|
||||
nextScrollTop: 100,
|
||||
nextScrollLeft,
|
||||
moved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes line deltas with the configured row height", () => {
|
||||
expect(resolveWheel({ deltaX: 2, deltaY: -3, deltaMode: 1 })).toMatchObject({
|
||||
scrollDeltaX: 56,
|
||||
scrollDeltaY: -84,
|
||||
nextScrollTop: 16,
|
||||
nextScrollLeft: 256,
|
||||
moved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes page deltas with the matching viewport dimensions", () => {
|
||||
expect(resolveWheel({ deltaX: 1, deltaY: 1, deltaMode: 2 })).toMatchObject({
|
||||
scrollDeltaX: 400,
|
||||
scrollDeltaY: 300,
|
||||
nextScrollTop: 400,
|
||||
nextScrollLeft: 600,
|
||||
moved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("turns Shift plus deltaY into horizontal-only movement", () => {
|
||||
expect(resolveWheel({ deltaY: 40, shiftKey: true, accelerationFactor: 1.5 })).toMatchObject({
|
||||
scrollDeltaX: 60,
|
||||
scrollDeltaY: 0,
|
||||
nextScrollTop: 100,
|
||||
nextScrollLeft: 260,
|
||||
moved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps plain deltaY vertical", () => {
|
||||
expect(resolveWheel({ deltaY: 40 })).toMatchObject({
|
||||
scrollDeltaX: 0,
|
||||
scrollDeltaY: 40,
|
||||
nextScrollTop: 140,
|
||||
nextScrollLeft: 200,
|
||||
moved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves both pixel axes without Canvas acceleration for diagonal input", () => {
|
||||
expect(resolveWheel({ deltaX: 30, deltaY: 20, accelerationFactor: 1.5 })).toMatchObject({
|
||||
scrollDeltaX: 30,
|
||||
scrollDeltaY: 20,
|
||||
nextScrollTop: 120,
|
||||
nextScrollLeft: 230,
|
||||
moved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ ctrlKey: true }, { metaKey: true }])("leaves modified wheel input untouched (%o)", (modifier) => {
|
||||
expect(resolveWheel({ deltaX: 120, deltaY: 40, ...modifier })).toEqual({
|
||||
scrollDeltaX: 0,
|
||||
scrollDeltaY: 0,
|
||||
nextScrollTop: 100,
|
||||
nextScrollLeft: 200,
|
||||
moved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps movement to both scroll boundaries", () => {
|
||||
expect(resolveWheel({ deltaX: 1000, deltaY: -1000 })).toMatchObject({
|
||||
nextScrollTop: 0,
|
||||
nextScrollLeft: 600,
|
||||
moved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hands horizontal input to an outer scroller at the relevant boundary", () => {
|
||||
expect(
|
||||
resolveWheel({
|
||||
deltaX: 120,
|
||||
metrics: { ...metrics, scrollLeft: 600 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
nextScrollTop: 100,
|
||||
nextScrollLeft: 600,
|
||||
moved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trigger infinite pagination for horizontal-only movement", () => {
|
||||
const wheelScroll = resolveWheel({ deltaX: 120 });
|
||||
|
||||
expect(shouldCheckInfiniteScrollAfterScroll({ top: metrics.scrollTop, left: metrics.scrollLeft }, { top: wheelScroll.nextScrollTop, left: wheelScroll.nextScrollLeft })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not consume input when the grid has no overflow", () => {
|
||||
expect(
|
||||
resolveWheel({
|
||||
deltaX: 120,
|
||||
deltaY: 40,
|
||||
metrics: {
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
scrollHeight: 300,
|
||||
scrollWidth: 400,
|
||||
clientHeight: 300,
|
||||
clientWidth: 400,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
nextScrollTop: 0,
|
||||
nextScrollLeft: 0,
|
||||
moved: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface DataGridWheelMetrics {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
scrollHeight: number;
|
||||
scrollWidth: number;
|
||||
clientHeight: number;
|
||||
clientWidth: number;
|
||||
}
|
||||
|
||||
export interface DataGridWheelInput {
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
deltaMode: number;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
lineSize: number;
|
||||
metrics: DataGridWheelMetrics;
|
||||
accelerationFactor?: number;
|
||||
}
|
||||
|
||||
export interface DataGridWheelScroll {
|
||||
scrollDeltaX: number;
|
||||
scrollDeltaY: number;
|
||||
nextScrollTop: number;
|
||||
nextScrollLeft: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
const DOM_DELTA_PIXEL = 0;
|
||||
const DOM_DELTA_LINE = 1;
|
||||
const DOM_DELTA_PAGE = 2;
|
||||
|
||||
function wheelDeltaToPixels(delta: number, deltaMode: number, lineSize: number, pageSize: number): number {
|
||||
if (deltaMode === DOM_DELTA_LINE) return delta * lineSize;
|
||||
if (deltaMode === DOM_DELTA_PAGE) return delta * pageSize;
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function resolveDataGridWheelScroll(input: DataGridWheelInput): DataGridWheelScroll {
|
||||
const { metrics } = input;
|
||||
if (input.ctrlKey || input.metaKey) {
|
||||
return {
|
||||
scrollDeltaX: 0,
|
||||
scrollDeltaY: 0,
|
||||
nextScrollTop: metrics.scrollTop,
|
||||
nextScrollLeft: metrics.scrollLeft,
|
||||
moved: false,
|
||||
};
|
||||
}
|
||||
|
||||
const accelerationFactor = input.accelerationFactor ?? 1;
|
||||
const normalizedDeltaX = wheelDeltaToPixels(input.deltaX, input.deltaMode, input.lineSize, metrics.clientWidth);
|
||||
const normalizedDeltaY = wheelDeltaToPixels(input.deltaY, input.deltaMode, input.lineSize, metrics.clientHeight);
|
||||
const shiftedDeltaY = input.shiftKey && Math.abs(normalizedDeltaY) > Math.abs(normalizedDeltaX) ? normalizedDeltaY : 0;
|
||||
// Native pixel deltas are already expressed in CSS pixels. When a device reports deltaX,
|
||||
// preserve both axes at 1:1 so diagonal trackpad input is not distorted by Canvas acceleration.
|
||||
const hasNativePixelDeltaX = input.deltaMode === DOM_DELTA_PIXEL && input.deltaX !== 0;
|
||||
const horizontalAcceleration = hasNativePixelDeltaX ? 1 : accelerationFactor;
|
||||
const verticalAcceleration = hasNativePixelDeltaX ? 1 : accelerationFactor;
|
||||
const scrollDeltaX = normalizedDeltaX * horizontalAcceleration + shiftedDeltaY * accelerationFactor;
|
||||
const scrollDeltaY = shiftedDeltaY === 0 ? normalizedDeltaY * verticalAcceleration : 0;
|
||||
const maxScrollTop = Math.max(0, metrics.scrollHeight - metrics.clientHeight);
|
||||
const maxScrollLeft = Math.max(0, metrics.scrollWidth - metrics.clientWidth);
|
||||
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, metrics.scrollTop + scrollDeltaY));
|
||||
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, metrics.scrollLeft + scrollDeltaX));
|
||||
|
||||
return {
|
||||
scrollDeltaX,
|
||||
scrollDeltaY,
|
||||
nextScrollTop,
|
||||
nextScrollLeft,
|
||||
moved: nextScrollTop !== metrics.scrollTop || nextScrollLeft !== metrics.scrollLeft,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user