Skip to content

Commit

Permalink
feat: add utils and hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
nahoc committed Apr 2, 2024
1 parent 0394fc2 commit 20b3b2c
Show file tree
Hide file tree
Showing 7 changed files with 198 additions and 8 deletions.
48 changes: 48 additions & 0 deletions hooks/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends HTMLElement = HTMLDivElement>(
eventName: keyof WindowEventMap | string, // string to allow custom event
handler: (event: Event) => void,
element?: RefObject<T>,
) => {
// 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;
109 changes: 109 additions & 0 deletions hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -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<T> = Dispatch<SetStateAction<T>>;

/**
* 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 = <T>(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 = <T>(key: string, initialValue: T): [T, SetValue<T>] => {
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<T>(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<T>(readValue);

// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = (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;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 0 additions & 7 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
/* Path Aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
12 changes: 12 additions & 0 deletions utils/parseJSON.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// A wrapper for "JSON.parse()"" to support "undefined" value
const parseJSON = <T>(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;
24 changes: 24 additions & 0 deletions utils/setClipboard.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions utils/sleep.ts
Original file line number Diff line number Diff line change
@@ -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));
}

0 comments on commit 20b3b2c

Please sign in to comment.