Added useSlider hook and mouse interaction test.
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LeftMenu, RightMenu } from "../../src/components/SideMenu";
|
import { LeftMenu, RightMenu } from "../SideMenu";
|
||||||
|
|
||||||
// Test Fixtures
|
// Test Fixtures
|
||||||
function newTestApp(position: "left" | "right") {
|
function newTestApp(position: "left" | "right") {
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
export function useSmoothAnimation() {
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const smoothAnimation = useCallback((callback: () => void) => {
|
||||||
|
if (animationRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(callback);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return smoothAnimation;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function handleScroll(
|
||||||
|
prevLength: number,
|
||||||
|
scrollDelta: number,
|
||||||
|
handleIncrement: () => void,
|
||||||
|
handleDecrement: () => void,
|
||||||
|
) {
|
||||||
|
const newLength = prevLength + scrollDelta;
|
||||||
|
|
||||||
|
if (Math.abs(newLength) > 50) {
|
||||||
|
if (newLength > 0) {
|
||||||
|
handleIncrement();
|
||||||
|
} else if (newLength < 0) {
|
||||||
|
handleDecrement();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newLength;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import type { Dispatch, SetStateAction, RefObject } from "react";
|
||||||
|
import { minmax } from "../util";
|
||||||
|
import type { CartesianSpace } from "../types";
|
||||||
|
import { handleScroll } from "./scroll";
|
||||||
|
|
||||||
|
export enum Direction {
|
||||||
|
HORIZONTAL = "horizontal",
|
||||||
|
VERTICAL = "vertical",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSlider({
|
||||||
|
direction,
|
||||||
|
origin,
|
||||||
|
dimensions,
|
||||||
|
setPosition,
|
||||||
|
}: {
|
||||||
|
direction: Direction;
|
||||||
|
origin: CartesianSpace;
|
||||||
|
dimensions: CartesianSpace;
|
||||||
|
setPosition: Dispatch<SetStateAction<number>>;
|
||||||
|
}): { sliderRef: RefObject<HTMLDivElement | null>; isDragging: boolean } {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const sliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [_, setScrollLength] = useState(0);
|
||||||
|
|
||||||
|
function calculatePosition(event: MouseEvent | TouchEvent) {
|
||||||
|
const clientCoord =
|
||||||
|
event instanceof TouchEvent
|
||||||
|
? direction === Direction.HORIZONTAL
|
||||||
|
? event.touches[0].clientX
|
||||||
|
: event.touches[0].clientY
|
||||||
|
: direction === Direction.HORIZONTAL
|
||||||
|
? event.clientX
|
||||||
|
: event.clientY;
|
||||||
|
const positionValue = minmax(
|
||||||
|
clientCoord - (direction === Direction.HORIZONTAL ? origin.x : origin.y),
|
||||||
|
0,
|
||||||
|
direction === Direction.HORIZONTAL ? dimensions.x : dimensions.y,
|
||||||
|
);
|
||||||
|
setPosition(positionValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSliderInteraction(event: MouseEvent | TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
calculatePosition(event);
|
||||||
|
setIsDragging(true);
|
||||||
|
if (event instanceof MouseEvent) {
|
||||||
|
document.addEventListener("mousemove", processSliderInteraction);
|
||||||
|
document.addEventListener("mouseup", endSliderInteraction);
|
||||||
|
} else {
|
||||||
|
document.addEventListener("touchmove", processSliderInteraction);
|
||||||
|
document.addEventListener("touchend", endSliderInteraction);
|
||||||
|
document.addEventListener("touchcancel", endSliderInteraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSliderInteraction(event: MouseEvent | TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
calculatePosition(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSliderInteraction(event: MouseEvent | TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (event instanceof MouseEvent) {
|
||||||
|
document.removeEventListener("mousemove", processSliderInteraction);
|
||||||
|
document.removeEventListener("mouseup", endSliderInteraction);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener("touchmove", processSliderInteraction);
|
||||||
|
document.removeEventListener("touchend", endSliderInteraction);
|
||||||
|
document.removeEventListener("touchcancel", endSliderInteraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustPositionWithScroll(event: WheelEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setScrollLength((prev) =>
|
||||||
|
handleScroll(
|
||||||
|
prev,
|
||||||
|
direction === Direction.HORIZONTAL ? event.deltaY : -event.deltaY,
|
||||||
|
() =>
|
||||||
|
setPosition((prev: number) =>
|
||||||
|
minmax(
|
||||||
|
prev - 1,
|
||||||
|
0,
|
||||||
|
direction === Direction.HORIZONTAL ? dimensions.x : dimensions.y,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
setPosition((prev: number) =>
|
||||||
|
minmax(
|
||||||
|
prev + 1,
|
||||||
|
0,
|
||||||
|
direction === Direction.HORIZONTAL ? dimensions.x : dimensions.y,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sliderRef.current) {
|
||||||
|
sliderRef.current.addEventListener("wheel", adjustPositionWithScroll);
|
||||||
|
sliderRef.current.addEventListener("mousedown", startSliderInteraction);
|
||||||
|
sliderRef.current.addEventListener("touchstart", startSliderInteraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (sliderRef.current) {
|
||||||
|
sliderRef.current.removeEventListener(
|
||||||
|
"wheel",
|
||||||
|
adjustPositionWithScroll,
|
||||||
|
);
|
||||||
|
sliderRef.current.removeEventListener(
|
||||||
|
"mousedown",
|
||||||
|
startSliderInteraction,
|
||||||
|
);
|
||||||
|
sliderRef.current.removeEventListener(
|
||||||
|
"touchstart",
|
||||||
|
startSliderInteraction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sliderRef, isDragging };
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useSlider, Direction } from "../slider";
|
||||||
|
import type { CartesianSpace } from "../../types";
|
||||||
|
|
||||||
|
// Test Fixtures
|
||||||
|
|
||||||
|
function printMeasurements(name: string, rect: DOMRect | undefined) {
|
||||||
|
console.log(
|
||||||
|
`${name} Measurements: (${rect?.left}, ${rect?.top}), (${rect?.width}, ${rect?.height})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestSlider() {
|
||||||
|
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
|
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
|
||||||
|
const [position, setPosition] = useState(0);
|
||||||
|
const { sliderRef, isDragging } = useSlider({
|
||||||
|
direction: Direction.HORIZONTAL,
|
||||||
|
origin,
|
||||||
|
dimensions,
|
||||||
|
setPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
const railRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
// Set slider dimensions post render
|
||||||
|
useEffect(() => {
|
||||||
|
const element = railRef.current;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
setOrigin({ x: rect.left, y: rect.top });
|
||||||
|
setDimensions({ x: rect.width, y: rect!.height });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-cy="slider-container"
|
||||||
|
ref={sliderRef}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: 375,
|
||||||
|
height: 50,
|
||||||
|
border: "3px solid black",
|
||||||
|
margin: 50,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-cy="slider-rail"
|
||||||
|
ref={railRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 25,
|
||||||
|
right: 25,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: "rgb(200,200,200)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
data-cy="slider-handle"
|
||||||
|
ref={handleRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: position,
|
||||||
|
top: 0,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
background: "rgba(255,0,0,0.5)",
|
||||||
|
cursor: isDragging ? "grabbing" : "grab",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Position: <span data-cy="position-display">{position}</span>px
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
|
||||||
|
describe("Slider Hook Tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.mount(<TestSlider />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const assertPosition = (expectedPosition: number) => {
|
||||||
|
cy.dataCy("position-display").should("contain", expectedPosition);
|
||||||
|
cy.dataCy("slider-handle").should(
|
||||||
|
"have.css",
|
||||||
|
"left",
|
||||||
|
`${expectedPosition}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerMouseEvent = (eventType: string, x: number, y: number) => {
|
||||||
|
cy.dataCy("slider-container").trigger(eventType, {
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
eventConstructor: "MouseEvent",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("moves the slider with mouse drag.", () => {
|
||||||
|
assertPosition(0);
|
||||||
|
|
||||||
|
triggerMouseEvent("mousedown", 86, 53);
|
||||||
|
assertPosition(0);
|
||||||
|
|
||||||
|
triggerMouseEvent("mousemove", 150, 150);
|
||||||
|
assertPosition(64);
|
||||||
|
|
||||||
|
triggerMouseEvent("mousemove", 500, 500);
|
||||||
|
assertPosition(325);
|
||||||
|
|
||||||
|
triggerMouseEvent("mousemove", 250, 250);
|
||||||
|
assertPosition(164);
|
||||||
|
|
||||||
|
triggerMouseEvent("mouseup", 250, 250);
|
||||||
|
assertPosition(164);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface CartesianSpace {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function minmax(number: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, number));
|
||||||
|
}
|
||||||
+2
-1
@@ -6,6 +6,7 @@
|
|||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"types": ["cypress", "node", "cypress-real-events"],
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
@@ -22,5 +23,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "cypress"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"module": "commonjs",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"lib": ["es5", "dom"],
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"types": ["cypress", "node"],
|
|
||||||
"sourceMap": true,
|
|
||||||
"preserveValueImports": false
|
|
||||||
},
|
|
||||||
"include": ["**/*.ts", "**/*.tsx"]
|
|
||||||
}
|
|
||||||
+1
-2
@@ -2,7 +2,6 @@
|
|||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" },
|
{ "path": "./tsconfig.node.json" }
|
||||||
{ "path": "./tsconfig.cypress.json" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user