diff --git a/cypress/component/SideMenu.cy.tsx b/src/components/SideMenu/tests/SideMenuTest.cy.tsx similarity index 97% rename from cypress/component/SideMenu.cy.tsx rename to src/components/SideMenu/tests/SideMenuTest.cy.tsx index fa173ed..4d04581 100644 --- a/cypress/component/SideMenu.cy.tsx +++ b/src/components/SideMenu/tests/SideMenuTest.cy.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { LeftMenu, RightMenu } from "../../src/components/SideMenu"; +import { LeftMenu, RightMenu } from "../SideMenu"; // Test Fixtures function newTestApp(position: "left" | "right") { diff --git a/src/hooks/animation.ts b/src/hooks/animation.ts new file mode 100644 index 0000000..e177fb7 --- /dev/null +++ b/src/hooks/animation.ts @@ -0,0 +1,21 @@ +import { useRef, useCallback } from "react"; + +export function useSmoothAnimation() { + const animationRef = useRef(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; +} diff --git a/src/hooks/crosshair.tsx b/src/hooks/crosshair.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/scroll.ts b/src/hooks/scroll.ts new file mode 100644 index 0000000..7cefe15 --- /dev/null +++ b/src/hooks/scroll.ts @@ -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; +} diff --git a/src/hooks/slider.tsx b/src/hooks/slider.tsx new file mode 100644 index 0000000..2297d4c --- /dev/null +++ b/src/hooks/slider.tsx @@ -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>; +}): { sliderRef: RefObject; isDragging: boolean } { + const [isDragging, setIsDragging] = useState(false); + const sliderRef = useRef(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 }; +} diff --git a/src/hooks/tests/crosshairTest.cy.tsx b/src/hooks/tests/crosshairTest.cy.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/tests/scrollTest.cy.tsx b/src/hooks/tests/scrollTest.cy.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/tests/sliderTest.cy.tsx b/src/hooks/tests/sliderTest.cy.tsx new file mode 100644 index 0000000..05cfe01 --- /dev/null +++ b/src/hooks/tests/sliderTest.cy.tsx @@ -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({ x: 0, y: 0 }); + const [dimensions, setDimensions] = useState({ x: 0, y: 0 }); + const [position, setPosition] = useState(0); + const { sliderRef, isDragging } = useSlider({ + direction: Direction.HORIZONTAL, + origin, + dimensions, + setPosition, + }); + + const railRef = useRef(null); + const handleRef = useRef(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 ( + <> +
+ + +
+

+ Position: {position}px +

+ + ); +} + +// Tests + +describe("Slider Hook Tests", () => { + beforeEach(() => { + cy.mount(); + }); + + 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); + }); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fa8cc07 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +export interface CartesianSpace { + x: number; + y: number; +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..37924e4 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,3 @@ +export function minmax(number: number, min: number, max: number) { + return Math.min(max, Math.max(min, number)); +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 9c8a3c4..8f19b42 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,6 +6,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "types": ["cypress", "node", "cypress-real-events"], /* Bundler mode */ "moduleResolution": "bundler", @@ -22,5 +23,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src", "cypress"] } diff --git a/tsconfig.cypress.json b/tsconfig.cypress.json deleted file mode 100644 index f38618e..0000000 --- a/tsconfig.cypress.json +++ /dev/null @@ -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"] -} diff --git a/tsconfig.json b/tsconfig.json index bf50381..1ffef60 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.cypress.json" } + { "path": "./tsconfig.node.json" } ] }