diff --git a/src/ReactFullpageSlideshow.tsx b/src/ReactFullpageSlideshow.tsx index 585f7ec..357cb73 100644 --- a/src/ReactFullpageSlideshow.tsx +++ b/src/ReactFullpageSlideshow.tsx @@ -1,14 +1,35 @@ -import React, { useCallback, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { GoToSlide, ReactFullpageSlideshowItem, rfsApi } from "./types"; +import { isMouseEvent, isPointerEvent } from "./typeguards"; export default function ReactFullpageSlideshow({ items, + itemClassName = "", + slideAnimationMs = 1000, + swipeMinThresholdMs = 50, + swipeMaxThresholdMs = 300, + swipeMinDistance = 100, }: { items: ReactFullpageSlideshowItem[]; + itemClassName?: string; + slideAnimationMs?: number; + swipeMinThresholdMs?: number; + swipeMaxThresholdMs?: number; + swipeMinDistance?: number; }) { + // activeIndex ref and state should always be set together. const activeIndexRef = useRef(0); const [activeIndex, setActiveIndex] = useState(0); + // yOffset ref and state should always be set together. + const yOffsetRef = useRef(0); + const [yOffset, setYOffset] = useState(0); + + // keep track of when/where a pointer or touch event started + const pointerStartData = useRef< + undefined | { timestamp: number; y: number } + >(); + const goToSlide = useCallback( (index: number) => { if (index >= 0 && index < items.length) { @@ -19,12 +40,105 @@ export default function ReactFullpageSlideshow({ [setActiveIndex], ); + const pointerDownCb = useCallback( + (event: PointerEvent | TouchEvent | MouseEvent) => { + let y = 0; + + if (isPointerEvent(event) || isMouseEvent(event)) { + y = event.y; + } else { + y = event.changedTouches["0"].clientY; + } + + pointerStartData.current = { + timestamp: Date.now(), + y, + }; + }, + [], + ); + + const pointerCancelCb = useCallback((e: unknown) => { + console.log(e); + console.log("cancelled?"); + }, []); + + const pointerUpCb = useCallback( + (event: PointerEvent | TouchEvent | MouseEvent) => { + setYOffset(0); + + let y = 0; + if (isPointerEvent(event) || isMouseEvent(event)) { + y = event.y; + } else { + y = event.changedTouches["0"].clientY; + } + + if (!pointerStartData.current) return; + const currentTs = Date.now(); + const isSwipe = + currentTs - pointerStartData.current?.timestamp < swipeMaxThresholdMs && + currentTs - pointerStartData.current?.timestamp > swipeMinThresholdMs && + Math.abs(pointerStartData.current.y - y) > swipeMinDistance; + const isDragged = + Math.abs(yOffsetRef.current) >= + document.documentElement.clientHeight / 2; + if (isSwipe || isDragged) { + if (y < pointerStartData.current.y) { + goToSlide(activeIndexRef.current + 1); + } else { + goToSlide(activeIndexRef.current - 1); + } + } + + yOffsetRef.current = 0; + pointerStartData.current = undefined; + }, + [ + goToSlide, + setYOffset, + swipeMaxThresholdMs, + swipeMinThresholdMs, + swipeMinDistance, + ], + ); + + useEffect(() => { + //addEventListener("wheel", wheelCb); + + // TODO: feature detect + addEventListener("pointerdown", pointerDownCb); + addEventListener("pointerup", pointerUpCb); + //addEventListener("pointermove", pointerMoveCb); + addEventListener("pointercancel", pointerCancelCb); + addEventListener("touchstart", pointerDownCb); + addEventListener("touchcancel", pointerCancelCb); + //addEventListener("touchmove", pointerMoveCb); + addEventListener("touchend", pointerUpCb); + + return () => { + //removeEventListener("wheel", wheelCb); + + removeEventListener("pointerdown", pointerDownCb); + removeEventListener("pointerup", pointerUpCb); + //removeEventListener("pointermove", pointerMoveCb); + removeEventListener("pointercancel", pointerCancelCb); + removeEventListener("touchstart", pointerDownCb); + removeEventListener("touchcancel", pointerCancelCb); + //removeEventListener("touchmove", pointerMoveCb); + removeEventListener("touchend", pointerUpCb); + }; + }, [pointerDownCb, pointerUpCb]); + const itemsWrapped = items.map((item, ind) => ( {item} @@ -51,11 +165,17 @@ const SlideContainer = ({ index, activeIndex, goToSlide, + className, + slideAnimationMs, + yOffset, }: { children: ReactFullpageSlideshowItem; index: number; activeIndex: number; goToSlide: GoToSlide; + className: string; + slideAnimationMs: number; + yOffset: number; }) => { const top = `${(index - activeIndex) * 100}vh`; @@ -76,6 +196,7 @@ const SlideContainer = ({ return (
{children(api)} diff --git a/src/Square.tsx b/src/Square.tsx deleted file mode 100644 index d7493d7..0000000 --- a/src/Square.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from "react"; - -export default function Square() { - return ; -} diff --git a/src/index.ts b/src/index.ts index eb4bfc0..511049b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ import ReactFullpageSlideshow from "./ReactFullpageSlideshow"; -import Square from "./Square"; -export { Square, ReactFullpageSlideshow }; +export { ReactFullpageSlideshow }; diff --git a/src/typeguards.ts b/src/typeguards.ts new file mode 100644 index 0000000..206fe0d --- /dev/null +++ b/src/typeguards.ts @@ -0,0 +1,10 @@ +export function isTouchEvent(e: object): e is TouchEvent { + return "touches" in e; +} + +export function isMouseEvent(e: object): e is MouseEvent { + return "clientY" in e && "ctrlKey" in e && e.ctrlKey !== undefined; +} +export function isPointerEvent(e: object): e is PointerEvent { + return "clientY" in e && "ctrlKey" in e && e.ctrlKey === undefined; +} diff --git a/test/BasicTest.test.tsx b/test/BasicTest.test.tsx index e42e438..4d0ca1f 100644 --- a/test/BasicTest.test.tsx +++ b/test/BasicTest.test.tsx @@ -8,6 +8,10 @@ import { rfsApi } from "../src/types"; describe("BasicTest", () => { it("renders", async () => { const user = userEvent.setup(); + jest + .spyOn(document.documentElement, "clientHeight", "get") + .mockImplementation(() => 500); + render(); expect(screen.getByText("slide 1").parentNode.parentNode).toHaveStyle( @@ -98,6 +102,7 @@ describe("BasicTest", () => { "top: 0vh", ); + // Clicking this should be a no-op because we are already on the last slide await user.click(screen.getByText("Next-Slide-slide 5")); expect(screen.getByText("slide 1").parentNode.parentNode).toHaveStyle( @@ -208,7 +213,7 @@ describe("BasicTest", () => { }); }); -const App = () => { +export const App = () => { return (
{ + jest + .spyOn(document.documentElement, "clientHeight", "get") + .mockImplementation(() => 500); + + render(); + const main = screen.getByRole("main"); + + fireEvent(main, new PointerEvent("pointerup")); + + expect(screen.getByText("slide 1").parentNode.parentNode).toHaveStyle( + "top: -100vh", + ); + expect(screen.getByText("slide 2").parentNode.parentNode).toHaveStyle( + "top: 0vh", + ); + expect(screen.getByText("slide 3").parentNode.parentNode).toHaveStyle( + "top: 100vh", + ); + expect(screen.getByText("slide 4").parentNode.parentNode).toHaveStyle( + "top: 200vh", + ); + expect(screen.getByText("slide 5").parentNode.parentNode).toHaveStyle( + "top: 300vh", + ); +});