From 1f20501265e508dbc399ff35c3576187ee2c7531 Mon Sep 17 00:00:00 2001 From: Konstantin Lukas Date: Sun, 1 Sep 2024 14:18:40 +0200 Subject: [PATCH 1/3] implement useEvent --- examples/App.tsx | 4 +- examples/components/demoUseEvent.tsx | 31 ++++++++++++++ examples/index.tsx | 3 +- src/hooks/useEvent.ts | 60 ++++++++++++++++++++++++++++ src/hooks/useFetch.ts | 2 +- src/hooks/useIntersectionObserver.ts | 15 ++----- src/index.ts | 39 +++++++++++++++--- tests/useEvent.test.tsx | 0 8 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 examples/components/demoUseEvent.tsx create mode 100644 src/hooks/useEvent.ts create mode 100644 tests/useEvent.test.tsx diff --git a/examples/App.tsx b/examples/App.tsx index 89e4d0f..7dc5a08 100644 --- a/examples/App.tsx +++ b/examples/App.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import DemoUseIntersectionObserver from "./components/demoUseIntersectionObserver"; +import DemoUseEvent from "./components/demoUseEvent"; const App: React.FC = () => { return ( - + ); }; diff --git a/examples/components/demoUseEvent.tsx b/examples/components/demoUseEvent.tsx new file mode 100644 index 0000000..f69481c --- /dev/null +++ b/examples/components/demoUseEvent.tsx @@ -0,0 +1,31 @@ +import React, {useEffect} from "react"; +import { useEvent } from "../../src"; + +const DemoUseEvent = () => { + const clickTarget = useEvent("click", (e) => { + const t = (e.target as HTMLDivElement); + t.style.backgroundColor = t.style.backgroundColor === "red" ? "green" : "red"; + }); + + const windowTarget = useEvent("scroll", _ => console.log("scroll")); + useEffect(() => { + windowTarget(document); + }, [windowTarget]); + + return ( +
+ Click me :) +
+ ); +}; + +export default DemoUseEvent; diff --git a/examples/index.tsx b/examples/index.tsx index b9fac30..15b08c3 100644 --- a/examples/index.tsx +++ b/examples/index.tsx @@ -2,5 +2,6 @@ import React from 'react'; import App from './App'; import { createRoot } from 'react-dom/client'; -const root = createRoot(document.getElementById('root')); +// @ts-ignore +const root = createRoot(document.getElementById("root")); root.render(); diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts new file mode 100644 index 0000000..cf5fc37 --- /dev/null +++ b/src/hooks/useEvent.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; + +/** + * Provides a wrapper around the EventListener API. Use the return value to define the event target. + * @param type - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#type See type on mdn web docs} + * @param listener - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#listener See listener on mdn web docs} + * @param options - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options See options on mdn web docs} + * @param options.catpure - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#capture See capture on mdn web docs} + * @param options.once - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#once See once on mdn web docs} + * @param options.passive - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#passive See passive on mdn web docs} + * @template T - The type of the event target. + * + * @return {@link SetStateAction} - A function which is used to assign the event target for convenience. The returned + * value can be used like a ref when the target is an HTMLElement. If the target is the {@link window} or + * {@link document}, you can use it like a regular {@link SetStateAction}. + * + * + * @example + * ```tsx + * const DemoUseEvent = () => { + * const clickTarget = useEvent("click", (e) => { + * const t = (e.target as HTMLDivElement); + * t.style.backgroundColor = t.style.backgroundColor === "red" ? "green" : "red"; + * }); + * + * const windowTarget = useEvent("scroll", _ => console.log("scroll")); + * useEffect(() => windowTarget(document), [windowTarget]); + * + * return ( + *
+ * Click me :) + *
+ * ); + * }; + * ``` + */ +function useEvent( + type: string, + listener: EventListener, + options?: { + capture?: boolean, + once?: boolean, + passive?: boolean, + signal?: AbortSignal, + }, +) { + const [target, setTarget] = useState(null); + useEffect(() => { + if (target) { + target.addEventListener(type, listener, options); + return () => target.removeEventListener(type, listener, options); + } + }, [type, listener, target, options]); + return setTarget; +} + +export default useEvent; \ No newline at end of file diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts index 1d8d557..4101f54 100644 --- a/src/hooks/useFetch.ts +++ b/src/hooks/useFetch.ts @@ -53,7 +53,7 @@ export interface FetchOptions { * * @param url - The resource to fetch * @param {FetchOptions} options - Allows you to configure how useFetch makes and returns requests - * @return FetchResult - An object containing information about the result of a fetch request + * @return {@link FetchResult} - An object containing information about the result of a fetch request * * @example * ```tsx diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts index b49e64a..6ad77ba 100644 --- a/src/hooks/useIntersectionObserver.ts +++ b/src/hooks/useIntersectionObserver.ts @@ -1,20 +1,11 @@ import { type RefObject, useEffect, useRef, useState } from "react"; export interface IntersectionObserverOptions { - /** An Element or Document object which is an ancestor of the intended target, whose bounding rectangle - * will be considered the viewport. Any part of the target not visible in the visible area of the root is not considered - * visible. Default is null. Setting the root to null, makes the hook observe intersections with the viewport. */ + /** Read on {@link https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#root mdn web docs}. */ root?: Element | Document | null, - /** A string which specifies a set of offsets to add to the root's bounding_box when - * calculating intersections, effectively shrinking or growing the root for calculation purposes. The syntax is - * approximately the same as that for the CSS margin property; see The intersection root and root margin for more - * information on how the margin works and the syntax. The default is "0px 0px 0px 0px".*/ + /** Read on {@link https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#rootmargin mdn web docs}. */ rootMargin?: string, - - /** Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of - * intersection area to total bounding box area for the observed target. A value of 0.0 means that even a single visible - * pixel counts as the target being visible. 1.0 means that the entire target element is visible. See Thresholds for a - * more in-depth description of how thresholds are used. The default is a threshold of 0.0. */ + /** Read on {@link https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#threshold mdn web docs}. */ threshold?: number | number[], } diff --git a/src/index.ts b/src/index.ts index 2493465..88ddf82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,33 @@ -export { default as useFetch, FetchResult, FetchResultData, ParseType, FetchOptions } from "./hooks/useFetch"; -export { default as useDefer } from "./hooks/useDefer"; -export { default as useFirstRender } from "./hooks/useFirstRender"; -export { default as useToggle } from "./hooks/useToggle"; -export { default as useIntersectionObserver, IntersectionObserverOptions } from "./hooks/useIntersectionObserver"; -export { default as useIntersectionObserverArray, IntersectionObserverArrayOptions } from "./hooks/useIntersectionObserverArray"; \ No newline at end of file +export { + default as useFetch, + type FetchResult, + type FetchResultData, + type ParseType, + type FetchOptions, +} from "./hooks/useFetch"; + +export { + default as useDefer, +} from "./hooks/useDefer"; + +export { + default as useFirstRender, +} from "./hooks/useFirstRender"; + +export { + default as useToggle, +} from "./hooks/useToggle"; + +export { + default as useIntersectionObserver, + type IntersectionObserverOptions, +} from "./hooks/useIntersectionObserver"; + +export { + default as useIntersectionObserverArray, + type IntersectionObserverArrayOptions, +} from "./hooks/useIntersectionObserverArray"; + +export { + default as useEvent, +} from "./hooks/useEvent"; \ No newline at end of file diff --git a/tests/useEvent.test.tsx b/tests/useEvent.test.tsx new file mode 100644 index 0000000..e69de29 From 0d58c28a9cf2413fe832665a6224b5436911fac9 Mon Sep 17 00:00:00 2001 From: Konstantin Lukas Date: Sun, 1 Sep 2024 14:27:28 +0200 Subject: [PATCH 2/3] update documentation --- README.md | 4 ++-- package.json | 2 +- src/hooks/useEvent.ts | 5 +++-- src/hooks/useFetch.ts | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 74e73fc..be34012 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ communicates intention. - useToggle: Provides a simple boolean toggle that does not persist between page reloads. - useIntersectionObserver: Provides a wrapper around the IntersectionObserver API, allowing you to test if an element is currently visible on screen. +- useEvent: Encapsulates the code needed to correctly listen to events in React, including event listener +cleanup. ## Installation Anzol is available on the NPM registry. To install it, just run: @@ -43,8 +45,6 @@ suggestions. user clicks anywhere outside the given element. - useLocalStorage: Provides access to local storage, with the additional option to update all usages of this hook when local storage changes. -- useEvent: Encapsulates the code needed to correctly listen to events in React, including event listener -cleanup. - usePreferredScheme: Listens for changes in the user's preferred scheme and returns it. - useDarkMode: Similar to usePreferredScheme but allows setting the user scheme manually and automatically updates it when the preferred scheme changes. Uses local storage to save the chosen scheme across reloads. diff --git a/package.json b/package.json index a622d5a..d278fec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anzol", - "version": "2.1.12", + "version": "2.2.0", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", "types": "dist/index.d.ts", diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts index cf5fc37..f5f7e10 100644 --- a/src/hooks/useEvent.ts +++ b/src/hooks/useEvent.ts @@ -8,11 +8,12 @@ import { useEffect, useState } from "react"; * @param options.catpure - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#capture See capture on mdn web docs} * @param options.once - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#once See once on mdn web docs} * @param options.passive - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#passive See passive on mdn web docs} + * @param options.signal - {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#signal See signal on mdn web docs} * @template T - The type of the event target. * - * @return {@link SetStateAction} - A function which is used to assign the event target for convenience. The returned + * @return {@link https://react.dev/reference/react/useState SetStateAction} - A function which is used to assign the event target for convenience. The returned * value can be used like a ref when the target is an HTMLElement. If the target is the {@link window} or - * {@link document}, you can use it like a regular {@link SetStateAction}. + * {@link document}, you can use it like a regular {@link https://react.dev/reference/react/useState useState} setter. * * * @example diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts index 4101f54..cad3d67 100644 --- a/src/hooks/useFetch.ts +++ b/src/hooks/useFetch.ts @@ -47,9 +47,9 @@ export interface FetchOptions { * assertions on the returned data. Here's a list of what parseTypes result in which return types: * - "html": {@link Document} * - "xml", "svg": {@link XMLDocument} - * - "text": {@link string} + * - "text": string * - "response": {@link Response} - * - "json" (default): {@link any} - When parsing JSON it is generally recommended to pass a custom type instead. + * - "json" (default): any - When parsing JSON it is generally recommended to pass a custom type instead. * * @param url - The resource to fetch * @param {FetchOptions} options - Allows you to configure how useFetch makes and returns requests From ff45d70002ccc2cb8a58d951f722e74b25343973 Mon Sep 17 00:00:00 2001 From: Konstantin Lukas Date: Sun, 1 Sep 2024 15:03:16 +0200 Subject: [PATCH 3/3] add basic tests for useEvent --- tests/useEvent.test.tsx | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/useEvent.test.tsx b/tests/useEvent.test.tsx index e69de29..3e633af 100644 --- a/tests/useEvent.test.tsx +++ b/tests/useEvent.test.tsx @@ -0,0 +1,60 @@ +import { useEvent } from "../src"; +import { fireEvent, render } from "@testing-library/react"; +import { screen } from "@testing-library/dom"; +import React, { useEffect } from "react"; + +function ElementEventComponent({ event }: { event: string }) { + const target = useEvent(event, (e) => { + const t = (e.target as HTMLDivElement); + t.style.backgroundColor = t.style.backgroundColor === "green" ? "red" : "green"; + }); + return ( +
+ Hello, world! +
+ ); +} + +function WindowEventComponent({ event, spyFunction }: { event: string, spyFunction: () => void }) { + const target = useEvent(event, spyFunction); + useEffect(() => { + target(window); + }, [target]); + return <>; +} + +test("should register events on elements and deregister them when props change", () => { + + const { rerender } = render(); + const target = screen.getByTestId("target-div"); + + expect(target.style.backgroundColor).not.toBe("red"); + expect(target.style.backgroundColor).not.toBe("green"); + fireEvent.mouseOver(target, { target }); + expect(target.style.backgroundColor).toBe("green"); + + rerender(); + fireEvent.mouseOver(target, { target }); + expect(target.style.backgroundColor).toBe("green"); + fireEvent.click(target, { target }); + expect(target.style.backgroundColor).toBe("red"); + +}); + +test("should register events on the window and deregister them when props change", () => { + + const spyFunction = jest.fn(); + const { rerender } = render(); + const target = window; + + expect(spyFunction).toHaveBeenCalledTimes(0); + fireEvent.mouseOver(target, { target }); + expect(spyFunction).toHaveBeenCalledTimes(1); + + rerender(); + fireEvent.mouseOver(target, { target }); + expect(spyFunction).toHaveBeenCalledTimes(1); + fireEvent.click(target, { target }); + expect(spyFunction).toHaveBeenCalledTimes(2); + +}); \ No newline at end of file