Added useSlider hook and mouse interaction test.

This commit is contained in:
Jay
2025-07-22 07:46:18 -04:00
parent 936c513a73
commit d224f335e9
13 changed files with 308 additions and 17 deletions
@@ -0,0 +1,128 @@
import { useState } from "react";
import { LeftMenu, RightMenu } from "../SideMenu";
// Test Fixtures
function newTestApp(position: "left" | "right") {
const Menu = position === "left" ? LeftMenu : RightMenu;
return function TestApp() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div
data-cy="app"
className="appContainer"
style={{ overflowX: "hidden" }}
>
<button
data-cy="toggle-menu"
onClick={() => setIsMenuOpen((prev) => !prev)}
>
Open Menu
</button>
<Menu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)}>
Menu Contents
</Menu>
<div data-cy="main-content">Main Content</div>
</div>
);
};
}
// Tests
function menuTest(position: "left" | "right") {
const isLeft = position === "left";
// 1. Check initial state
cy.dataCy(`${position}-menu`)
.as("menu")
.should("not.be.visible")
.contains("Menu Contents")
.should("not.be.visible");
// 2. Open menu
cy.dataCy("toggle-menu")
.as("toggle")
.click()
.get("@menu")
.should("be.visible")
.contains("Menu Contents")
.should("be.visible");
// 3. Close menu with button
cy.dataCy("close-menu")
.as("close")
.click()
.get("@menu")
.should("not.be.visible")
.contains("Menu Contents")
.should("not.be.visible");
// 4. Reopen menu, verify interactivity, then close with underlay
cy.get("@toggle")
.click()
.get("@menu")
.should("be.visible")
.click("center")
.get("@menu")
.should("be.visible")
.dataCy("app")
.click(isLeft ? "topRight" : "topLeft")
.get("@menu")
.should("not.be.visible");
}
beforeEach(() => {
cy.disableTransitions();
});
afterEach(() => {
cy.enableTransitions();
});
describe("LeftMenu Tests", () => {
beforeEach(() => {
const TestApp = newTestApp("left");
cy.mount(<TestApp />);
});
afterEach(() => {
cy.enableTransitions();
});
it("works in portrait", () => {
cy.viewport("iphone-6");
menuTest("left");
});
it("works in landscape", () => {
cy.viewport("iphone-6", "landscape");
menuTest("left");
});
});
describe("RightMenu Tests", () => {
beforeEach(() => {
const TestApp = newTestApp("right");
cy.mount(<TestApp />);
});
it("works in portrait", () => {
cy.viewport("iphone-6");
menuTest("right");
});
it("works in landscape", () => {
cy.viewport("iphone-6", "landscape");
menuTest("right");
});
});
+21
View File
@@ -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;
}
View File
+20
View File
@@ -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;
}
+128
View File
@@ -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 };
}
View File
+128
View File
@@ -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);
});
});
+4
View File
@@ -0,0 +1,4 @@
export interface CartesianSpace {
x: number;
y: number;
}
+3
View File
@@ -0,0 +1,3 @@
export function minmax(number: number, min: number, max: number) {
return Math.min(max, Math.max(min, number));
}