diff --git a/hooks/useEventListener.ts b/hooks/useEventListener.ts new file mode 100644 index 0000000..548559f --- /dev/null +++ b/hooks/useEventListener.ts @@ -0,0 +1,48 @@ +import { type RefObject, useEffect, useRef } from "react"; + +/** + * It returns a function that adds an event listener to a target element, and removes it when the + * component unmounts + * @param {keyof WindowEventMap | string} eventName - The name of the event to listen for. + * @param handler - The function that will be called when the event is fired. + * @param [element] - The element to listen to the event on. If not provided, it will default to + * window. + */ +const useEventListener = ( + eventName: keyof WindowEventMap | string, // string to allow custom event + handler: (event: Event) => void, + element?: RefObject, +) => { + // Create a ref that stores handler + const savedHandler = useRef<(event: Event) => void>(); + + useEffect(() => { + // Define the listening target + const targetElement: T | Window = element?.current ?? window; + + if (!targetElement?.addEventListener) { + return; + } + + // Update saved handler if necessary + if (savedHandler.current !== handler) { + savedHandler.current = handler; + } + + // Create event listener that calls handler function stored in ref + const eventListener = (event: Event) => { + if (savedHandler?.current) { + savedHandler.current(event); + } + }; + + targetElement.addEventListener(eventName, eventListener); + + // Remove event listener on cleanup + return () => { + targetElement.removeEventListener(eventName, eventListener); + }; + }, [eventName, element, handler]); +}; + +export default useEventListener; diff --git a/hooks/useLocalStorage.ts b/hooks/useLocalStorage.ts new file mode 100644 index 0000000..845cd16 --- /dev/null +++ b/hooks/useLocalStorage.ts @@ -0,0 +1,109 @@ +import { isEqual, isFunction, isNil } from "lodash-es"; +import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react"; +import parseJSON from "../utils/parseJSON"; +import useEventListener from "./useEventListener"; + +type SetValue = Dispatch>; + +/** + * Creating and read here and using window.setItem for writes. This avoids + * tapping into the react rendering pipeline unnecessarily when it isnt needed + * + * @param key the key to use to store + * @returns the value requested + */ +const readValueFromStorage = (key: string) => { + try { + const item = window.localStorage.getItem(key); + + const value = item && (parseJSON(item) as T); + + return value ?? undefined; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + + return { error: "unable to read value" }; + } +}; + +type StorageError = { + error: string; +}; + +const isError = (value: StorageError | any): value is StorageError => !!value?.error; + +const useLocalStorage = (key: string, initialValue: T): [T, SetValue] => { + const previousValueRef = useRef(initialValue); + + // Get from local storage then + // parse stored json or return initialValue + const readValue = (): T => { + // Prevent build error "window is undefined" but keep keep working + if (typeof window === "undefined") { + return initialValue; + } + + const value = readValueFromStorage(key); + + return isError(value) || isNil(value) || value === "" ? initialValue : value; + }; + + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue] = useState(readValue); + + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue: SetValue = (value) => { + // Prevent build error "window is undefined" but keeps working + if (typeof window === "undefined") { + console.warn(`Tried setting localStorage key "${key}" even though environment is not a client`); + } + + try { + // Allow value to be a function so we have the same API as useState + const newValue = isFunction(value) ? value(storedValue) : value; + + // Save to local storage + window.localStorage.setItem(key, JSON.stringify(newValue)); + + // Save state + setStoredValue(newValue); + + // We dispatch a custom event so every useLocalStorage hook are notified + window.dispatchEvent(new Event("local-storage")); + } catch (error) { + console.warn(`Error setting localStorage key "${key}":`, error); + } + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: run only once + useEffect(() => { + const newValue = readValue(); + setStoredValue(newValue); + previousValueRef.current = newValue; + // run only once + }, []); + + const handleStorageChange = () => { + const newValue = readValue(); + + if (isEqual(newValue, previousValueRef.current)) { + return; + } + setStoredValue(newValue); + previousValueRef.current = newValue; + // run only once + }; + + // this only works for other documents, not the current one + useEventListener("storage", handleStorageChange); + + // this is a custom event, triggered in writeValueToLocalStorage + // See: useLocalStorage() + useEventListener("local-storage", handleStorageChange); + + return [storedValue, setValue]; +}; + +export default useLocalStorage; diff --git a/package.json b/package.json index 2782a76..7f88687 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@risc0/ui", - "version": "0.0.21", + "version": "0.0.22", "type": "module", "scripts": { "bump:version": "bunx changelogen --bump --no-output", diff --git a/tsconfig.json b/tsconfig.json index a6bcd93..60e3a7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,5 @@ { "extends": "./tsconfig.base.json", - "compilerOptions": { - /* Path Aliases */ - "baseUrl": ".", - "paths": { - "~/*": ["./*"] - } - }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } diff --git a/utils/parseJSON.ts b/utils/parseJSON.ts new file mode 100644 index 0000000..1e389b9 --- /dev/null +++ b/utils/parseJSON.ts @@ -0,0 +1,12 @@ +// A wrapper for "JSON.parse()"" to support "undefined" value +const parseJSON = (value?: string | null): T | undefined => { + try { + return value === "undefined" ? undefined : (JSON.parse(value ?? "") as T); + } catch { + console.error("parsing error on", { value }); + + return undefined; + } +}; + +export default parseJSON; diff --git a/utils/setClipboard.ts b/utils/setClipboard.ts new file mode 100644 index 0000000..f012af9 --- /dev/null +++ b/utils/setClipboard.ts @@ -0,0 +1,24 @@ +import { toast } from "sonner"; + +type SetClipboardParams = { + onFailure?: () => void; + onSuccess?: () => void; + value: string; +}; + +const setClipboard = ({ + value, + onSuccess = () => toast.success("Copied to clipboard"), + onFailure = () => toast.error("Failed to copy to clipboard"), +}: SetClipboardParams) => { + navigator.clipboard + .writeText(value) + .then(() => { + onSuccess?.(); + }) + .catch(() => { + onFailure?.(); + }); +}; + +export default setClipboard; diff --git a/utils/sleep.ts b/utils/sleep.ts new file mode 100644 index 0000000..515b738 --- /dev/null +++ b/utils/sleep.ts @@ -0,0 +1,4 @@ +// Helpful function to sleep for a given amount of time +export default function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}