diff --git a/README.md b/README.md index e2050bb..c7a83f4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ # useHooks -A collection of modern, server-safe React hooks – from the ui.dev team +A collection of modern, server-safe React hooks – from the [ui.dev](https://ui.dev) team. + +Compatible with React v18.0.0+. ## Standard @@ -21,13 +23,14 @@ A collection of modern, server-safe React hooks – from the ui.dev team - [useDocumentTitle](https://usehooks.com/usedocumenttitle) - [useFavicon](https://usehooks.com/usefavicon) - [useGeolocation](https://usehooks.com/usegeolocation) -- [useHistoryState](https://usehooks.com/usehistoryState) +- [useHistoryState](https://usehooks.com/usehistorystate) - [useHover](https://usehooks.com/usehover) - [useIdle](https://usehooks.com/useidle) - [useIntersectionObserver](https://usehooks.com/useintersectionobserver) - [useIsClient](https://usehooks.com/useisclient) - [useIsFirstRender](https://usehooks.com/useisfirstrender) - [useList](https://usehooks.com/uselist) +- [useLocalStorage](https://usehooks.com/uselocalstorage) - [useLockBodyScroll](https://usehooks.com/uselockbodyscroll) - [useLongPress](https://usehooks.com/uselongpress) - [useMap](https://usehooks.com/usemap) @@ -43,6 +46,7 @@ A collection of modern, server-safe React hooks – from the ui.dev team - [useRenderCount](https://usehooks.com/userendercount) - [useRenderInfo](https://usehooks.com/userenderinfo) - [useScript](https://usehooks.com/usescript) +- [useSessionStorage](https://usehooks.com/usesessionstorage) - [useSet](https://usehooks.com/useset) - [useThrottle](https://usehooks.com/usethrottle) - [useToggle](https://usehooks.com/usetoggle) @@ -65,9 +69,7 @@ A collection of modern, server-safe React hooks – from the ui.dev team - [useInterval](https://usehooks.com/useinterval) - [useIntervalWhen](https://usehooks.com/useintervalwhen) - [useKeyPress](https://usehooks.com/usekeypress) -- [useLocalStorage](https://usehooks.com/uselocalstorage) - [useLogger](https://usehooks.com/uselogger) - [usePageLeave](https://usehooks.com/usepageleave) - [useRandomInterval](https://usehooks.com/userandominterval) -- [useSessionStorage](https://usehooks.com/usesessionstorage) - [useTimeout](https://usehooks.com/usetimeout) diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..db01dc2 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,263 @@ +import * as React from "react"; + +export type BatteryManager = { + supported: boolean; + loading: boolean; + level: number | null; + charging: boolean | null; + chargingTime: number | null; + dischargingTime: number | null; +}; + +export type GeolocationState = { + loading: boolean; + accuracy: number | null; + altitude: number | null; + altitudeAccuracy: number | null; + heading: number | null; + latitude: number | null; + longitude: number | null; + speed: number | null; + timestamp: number | null; + error: GeolocationPositionError | null; +}; + +export type HistoryState = { + state: T; + set: (newPresent: T) => void; + undo: () => void; + redo: () => void; + clear: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +export type LongPressOptions = { + threshold?: number; + onStart?: (e: Event) => void; + onFinish?: (e: Event) => void; + onCancel?: (e: Event) => void; +}; + +export type LongPressFns = { + onMouseDown: (e: React.MouseEvent) => void; + onMouseUp: (e: React.MouseEvent) => void; + onMouseLeave: (e: React.MouseEvent) => void; + onTouchStart: (e: React.TouchEvent) => void; + onTouchEnd: (e: React.TouchEvent) => void; +}; + +export type MousePosition = { + x: number; + y: number; + elementX: number; + elementY: number; + elementPositionX: number; + elementPositionY: number; +}; + +export type NetworkState = { + online: boolean; + downlink: number | null; + downlinkMax: number | null; + effectiveType: string | null; + rtt: number | null; + saveData: boolean | null; + type: string | null; +}; + +export type CustomList = { + set: (l: T[]) => void; + push: (element: T) => void; + removeAt: (index: number) => void; + insertAt: (index: number, element: T) => void; + updateAt: (index: number, element: T) => void; + clear: () => void; +}; + +export type CustomQueue = { + add: (element: T) => void; + remove: () => T | undefined; + clear: () => void; + first: T | undefined; + last: T | undefined; + size: number; + queue: T[]; +}; + +export type RenderInfo = { + name: string; + renders: number; + sinceLastRender: number; + timestamp: number; +}; + +export type SpeechOptions = { + lang?: string; + voice?: { + lang?: string; + name?: string; + }; + rate?: number; + pitch?: number; + volume?: number; +}; + +export type SpeechState = { + isPlaying: boolean; + status: "init" | "play" | "pause" | "stop"; + lang: string; + voiceInfo: { + lang: string; + name: string; + }; + rate: number; + pitch: number; + volume: number; +}; + +declare module "@uidotdev/usehooks" { + export function useBattery(): BatteryManager; + + export function useClickAway( + cb: (e: Event) => void + ): React.MutableRefObject; + + export function useCopyToClipboard(): [ + string | null, + (value: string) => Promise + ]; + + export function useCounter( + startingValue?: number, + options?: { + min?: number; + max?: number; + } + ): [ + number, + { + increment: () => void; + decrement: () => void; + set: (nextCount: number) => void; + reset: () => void; + } + ]; + + export function useDebounce(value: T, delay: number): T; + + export function useDefault( + initialValue: T, + defaultValue: T + ): [T, React.Dispatch>]; + + export function useDocumentTitle(title: string): void; + + export function useFavicon(url: string): void; + + export function useGeolocation(options?: PositionOptions): GeolocationState; + + export function useHistoryState(initialPresent?: T): HistoryState; + + export function useHover(): [ + React.RefCallback, + boolean + ]; + + export function useIdle(ms?: number): boolean; + + export function useIntersectionObserver( + options?: IntersectionObserverInit + ): [React.RefCallback, IntersectionObserverEntry | null]; + + export function useIsClient(): boolean; + + export function useIsFirstRender(): boolean; + + export function useList(defaultList?: T[]): [T[], CustomList]; + + export function useLocalStorage( + key: string, + initialValue?: T + ): [T, React.Dispatch>]; + + export function useLockBodyScroll(): void; + + export function useLongPress( + callback: (e: Event) => void, + options?: LongPressOptions + ): LongPressFns; + + export function useMap(initialState?: T): Map; + + export function useMeasure(): [ + React.RefCallback, + { + width: number | null; + height: number | null; + } + ]; + + export function useMediaQuery(query: string): boolean; + + export function useMouse(): [ + MousePosition, + React.MutableRefObject + ]; + + export function useNetworkState(): NetworkState; + + export function useObjectState(initialValue: T): [T, (arg: T) => void]; + + export function useOrientation(): { + angle: number; + type: string; + }; + + export function usePreferredLanguage(): string; + + export function usePrevious(newValue: T): T; + + export function useQueue(initialValue?: T[]): CustomQueue; + + export function useRenderCount(): number; + + export function useRenderInfo(name?: string): RenderInfo | undefined; + + export function useScript( + src: string, + options?: { + removeOnUnmount?: boolean; + } + ): "unknown" | "loading" | "ready" | "error"; + + export function useSessionStorage( + key: string, + initialValue: T + ): [T, React.Dispatch>]; + + export function useSet(values?: T[]): Set; + + export function useSpeech(text: string, options?: SpeechOptions): SpeechState; + + export function useThrottle(value: T, delay: number): T; + + export function useToggle( + initialValue?: boolean + ): [boolean, (newValue?: boolean) => void]; + + export function useVisibilityChange(): boolean; + + export function useWindowScroll(): [ + { + x: number | null; + y: number | null; + }, + (args: unknown) => void + ]; + + export function useWindowSize(): { + width: number | null; + height: number | null; + }; +} diff --git a/index.js b/index.js index 2101ea6..f6e4fe2 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,14 @@ function throttle(cb, ms) { }; } +function isPlainObject(value) { + return Object.prototype.toString.call(value) === "[object Object]"; +} + +function dispatchStorageEvent(key, newValue) { + window.dispatchEvent(new StorageEvent("storage", { key, newValue })); +} + export function useBattery() { const [state, setState] = React.useState({ supported: true, @@ -45,7 +53,7 @@ export function useBattery() { level: null, charging: null, chargingTime: null, - dischargingTime: null + dischargingTime: null, }); React.useEffect(() => { @@ -53,9 +61,9 @@ export function useBattery() { setState((s) => ({ ...s, supported: false, - loading: false + loading: false, })); - return + return; } let battery = null; @@ -67,7 +75,7 @@ export function useBattery() { level: battery.level, charging: battery.charging, chargingTime: battery.chargingTime, - dischargingTime: battery.dischargingTime + dischargingTime: battery.dischargingTime, }); }; @@ -86,7 +94,7 @@ export function useBattery() { battery.removeEventListener("levelchange", handleChange); battery.removeEventListener("chargingchange", handleChange); battery.removeEventListener("chargingtimechange", handleChange); - battery.removeEventListener("dischargingtimechange;", handleChange); + battery.removeEventListener("dischargingtimechange", handleChange); } }; }, []); @@ -98,6 +106,10 @@ export function useClickAway(cb) { const ref = React.useRef(null); const refCb = React.useRef(cb); + React.useLayoutEffect(() => { + refCb.current = cb; + }); + React.useEffect(() => { const handler = (e) => { const element = ref.current; @@ -118,35 +130,34 @@ export function useClickAway(cb) { return ref; } -export function useCopyToClipboard() { - const [state, setState] = React.useState({ - error: null, - text: null - }); - - const copyToClipboard = React.useCallback(async (value) => { - if (!navigator?.clipboard) { - return setState({ - error: new Error("Clipboard not supported"), - text: null - }); - } - - const handleSuccess = () => { - setState({ - error: null, - text: value - }); - }; +function oldSchoolCopy(text) { + const tempTextArea = document.createElement("textarea"); + tempTextArea.value = text; + document.body.appendChild(tempTextArea); + tempTextArea.select(); + document.execCommand("copy"); + document.body.removeChild(tempTextArea); +} - const handleFailure = (e) => { - setState({ - error: e, - text: null - }); +export function useCopyToClipboard() { + const [state, setState] = React.useState(null); + + const copyToClipboard = React.useCallback((value) => { + const handleCopy = async () => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + setState(value); + } else { + throw new Error("writeText not supported"); + } + } catch (e) { + oldSchoolCopy(value); + setState(value); + } }; - navigator.clipboard.writeText(value).then(handleSuccess, handleFailure); + handleCopy(); }, []); return [state, copyToClipboard]; @@ -155,13 +166,13 @@ export function useCopyToClipboard() { export function useCounter(startingValue = 0, options = {}) { const { min, max } = options; - if (min && startingValue < min) { + if (typeof min === "number" && startingValue < min) { throw new Error( `Your starting value of ${startingValue} is less than your min of ${min}.` ); } - if (max && startingValue > max) { + if (typeof max === "number" && startingValue > max) { throw new Error( `Your starting value of ${startingValue} is greater than your max of ${max}.` ); @@ -169,47 +180,50 @@ export function useCounter(startingValue = 0, options = {}) { const [count, setCount] = React.useState(startingValue); - const increment = () => { - const nextCount = count + 1; - if (max && nextCount > max) { - return; - } + const increment = React.useCallback(() => { + setCount((c) => { + const nextCount = c + 1; - setCount(nextCount); - }; + if (typeof max === "number" && nextCount > max) { + return c; + } - const decrement = () => { - const nextCount = count - 1; - if (min && nextCount < min) { - return; - } + return nextCount; + }); + }, [max]); - setCount(nextCount); - }; + const decrement = React.useCallback(() => { + setCount((c) => { + const nextCount = c - 1; - const set = (nextCount) => { - if (max && nextCount > max) { - return; - } + if (typeof min === "number" && nextCount < min) { + return c; + } - if (min && nextCount < min) { - return; - } + return nextCount; + }); + }, [min]); - if (nextCount === count) { - return; - } + const set = React.useCallback( + (nextCount) => { + setCount((c) => { + if (typeof max === "number" && nextCount > max) { + return c; + } - setCount(nextCount); - }; + if (typeof min === "number" && nextCount < min) { + return c; + } - const reset = () => { - if (count === startingValue) { - return; - } + return nextCount; + }); + }, + [max, min] + ); + const reset = React.useCallback(() => { setCount(startingValue); - }; + }, [startingValue]); return [ count, @@ -217,8 +231,8 @@ export function useCounter(startingValue = 0, options = {}) { increment, decrement, set, - reset - } + reset, + }, ]; } @@ -256,13 +270,17 @@ export function useDocumentTitle(title) { export function useFavicon(url) { React.useEffect(() => { - const link = - document.querySelector("link[rel*='icon']") || - document.createElement("link"); - link.type = "image/x-icon"; - link.rel = "shortcut icon"; - link.href = url; - document.getElementsByTagName("head")[0].appendChild(link); + let link = document.querySelector(`link[rel~="icon"]`); + + if (!link) { + link = document.createElement("link"); + link.type = "image/x-icon"; + link.rel = "icon"; + link.href = url; + document.head.appendChild(link); + } else { + link.href = url; + } }, [url]); } @@ -333,42 +351,42 @@ const initialUseHistoryStateState = { const useHistoryStateReducer = (state, action) => { const { past, present, future } = state; - switch (action.type) { - case "UNDO": - return { - past: past.slice(0, past.length - 1), - present: past[past.length - 1], - future: [present, ...future], - }; - case "REDO": - return { - past: [...past, present], - present: future[0], - future: future.slice(1), - }; - case "SET": - const { newPresent } = action; - if (action.newPresent === present) { - return state; - } + if (action.type === "UNDO") { + return { + past: past.slice(0, past.length - 1), + present: past[past.length - 1], + future: [present, ...future], + }; + } else if (action.type === "REDO") { + return { + past: [...past, present], + present: future[0], + future: future.slice(1), + }; + } else if (action.type === "SET") { + const { newPresent } = action; - return { - past: [...past, present], - present: newPresent, - future: [], - }; - case "CLEAR": - return { - ...initialUseHistoryStateState, - present: action.initialPresent, - }; - default: - throw new Error("Unsupported action type"); + if (action.newPresent === present) { + return state; + } + + return { + past: [...past, present], + present: newPresent, + future: [], + }; + } else if (action.type === "CLEAR") { + return { + ...initialUseHistoryStateState, + present: action.initialPresent, + }; + } else { + throw new Error("Unsupported action type"); } }; -export function useHistoryState (initialPresent = {}) { +export function useHistoryState(initialPresent = {}) { const initialPresentRef = React.useRef(initialPresent); const [state, dispatch] = React.useReducer(useHistoryStateReducer, { @@ -403,35 +421,44 @@ export function useHistoryState (initialPresent = {}) { ); return { state: state.present, set, undo, redo, clear, canUndo, canRedo }; -}; +} export function useHover() { const [hovering, setHovering] = React.useState(false); - const ref = React.useRef(null); - - React.useEffect(() => { - const node = ref.current; + const previousNode = React.useRef(null); - if (!node) return; + const handleMouseEnter = React.useCallback(() => { + setHovering(true); + }, []); - const handleMouseEnter = () => { - setHovering(true); - }; + const handleMouseLeave = React.useCallback(() => { + setHovering(false); + }, []); - const handleMouseLeave = () => { - setHovering(false); - }; + const customRef = React.useCallback( + (node) => { + if (previousNode.current?.nodeType === Node.ELEMENT_NODE) { + previousNode.current.removeEventListener( + "mouseenter", + handleMouseEnter + ); + previousNode.current.removeEventListener( + "mouseleave", + handleMouseLeave + ); + } - node.addEventListener("mouseenter", handleMouseEnter); - node.addEventListener("mouseleave", handleMouseLeave); + if (node?.nodeType === Node.ELEMENT_NODE) { + node.addEventListener("mouseenter", handleMouseEnter); + node.addEventListener("mouseleave", handleMouseLeave); + } - return () => { - node.removeEventListener("mouseenter", handleMouseEnter); - node.removeEventListener("mouseleave", handleMouseLeave); - }; - }, []); + previousNode.current = node; + }, + [handleMouseEnter, handleMouseLeave] + ); - return [ref, hovering]; + return [customRef, hovering]; } export function useIdle(ms = 1000 * 60) { @@ -483,33 +510,34 @@ export function useIdle(ms = 1000 * 60) { } export function useIntersectionObserver(options = {}) { - const { threshold = 1, root = null, rootMargin = "0%" } = options; - const ref = React.useRef(null); + const { threshold = 1, root = null, rootMargin = "0px" } = options; const [entry, setEntry] = React.useState(null); - React.useEffect(() => { - const node = ref?.current; + const previousObserver = React.useRef(null); - if (!node || typeof IntersectionObserver !== "function") { - return; - } - - const observer = new IntersectionObserver( - ([entry]) => { - setEntry(entry); - }, - { threshold, root, rootMargin } - ); + const customRef = React.useCallback( + (node) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } - observer.observe(node); + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new IntersectionObserver( + ([entry]) => { + setEntry(entry); + }, + { threshold, root, rootMargin } + ); - return () => { - setEntry(null); - observer.disconnect(); - }; - }, [threshold, root, rootMargin]); + observer.observe(node); + previousObserver.current = observer; + } + }, + [threshold, root, rootMargin] + ); - return [ref, entry]; + return [customRef, entry]; } export function useIsClient() { @@ -536,44 +564,95 @@ export function useIsFirstRender() { export function useList(defaultList = []) { const [list, setList] = React.useState(defaultList); - const methods = React.useMemo(() => { - const set = (l) => { - setList(l); - }; + const set = React.useCallback((l) => { + setList(l); + }, []); - const push = (element) => { - setList((l) => [...l, element]); - }; + const push = React.useCallback((element) => { + setList((l) => [...l, element]); + }, []); - const removeAt = (index) => { - setList((l) => [...l.slice(0, index), ...l.slice(index + 1)]); - }; + const removeAt = React.useCallback((index) => { + setList((l) => [...l.slice(0, index), ...l.slice(index + 1)]); + }, []); - const insertAt = (index, element) => { - setList((l) => [...l.slice(0, index), element, ...l.slice(index)]); - }; + const insertAt = React.useCallback((index, element) => { + setList((l) => [...l.slice(0, index), element, ...l.slice(index)]); + }, []); - const updateAt = (index, element) => { - setList((l) => l.map((e, i) => (i === index ? element : e))); - }; + const updateAt = React.useCallback((index, element) => { + setList((l) => l.map((e, i) => (i === index ? element : e))); + }, []); - const clear = () => setList([]); + const clear = React.useCallback(() => setList([]), []); - return { - set, - push, - removeAt, - insertAt, - updateAt, - clear, - }; - }, []); + return [list, { set, push, removeAt, insertAt, updateAt, clear }]; +} + +const setLocalStorageItem = (key, value) => { + const stringifiedValue = JSON.stringify(value); + window.localStorage.setItem(key, stringifiedValue); + dispatchStorageEvent(key, stringifiedValue); +}; + +const removeLocalStorageItem = (key) => { + window.localStorage.removeItem(key); + dispatchStorageEvent(key, null); +}; + +const getLocalStorageItem = (key) => { + return window.localStorage.getItem(key); +}; + +const useLocalStorageSubscribe = (callback) => { + window.addEventListener("storage", callback); + return () => window.removeEventListener("storage", callback); +}; + +const getLocalStorageServerSnapshot = () => { + throw Error("useLocalStorage is a client-only hook"); +}; + +export function useLocalStorage(key, initialValue) { + const getSnapshot = () => getLocalStorageItem(key); - return [list, methods]; + const store = React.useSyncExternalStore( + useLocalStorageSubscribe, + getSnapshot, + getLocalStorageServerSnapshot + ); + + const setState = React.useCallback( + (v) => { + try { + const nextState = typeof v === "function" ? v(JSON.parse(store)) : v; + + if (nextState === undefined || nextState === null) { + removeLocalStorageItem(key); + } else { + setLocalStorageItem(key, nextState); + } + } catch (e) { + console.warn(e); + } + }, + [key, store] + ); + + React.useEffect(() => { + if ( + getLocalStorageItem(key) === null && + typeof initialValue !== "undefined" + ) { + setLocalStorageItem(key, initialValue); + } + }, [key, initialValue]); + + return [store ? JSON.parse(store) : initialValue, setState]; } export function useLockBodyScroll() { - React.useEffect(() => { + React.useLayoutEffect(() => { const originalStyle = window.getComputedStyle(document.body).overflow; document.body.style.overflow = "hidden"; return () => { @@ -582,19 +661,18 @@ export function useLockBodyScroll() { }, []); } -export function useLongPress( - callback, - { threshold = 400, onStart, onFinish, onCancel } = {} -) { +export function useLongPress(callback, options = {}) { + const { threshold = 400, onStart, onFinish, onCancel } = options; const isLongPressActive = React.useRef(false); const isPressed = React.useRef(false); const timerId = React.useRef(); - const cbRef = React.useRef(callback); - const start = React.useCallback( - () => (event) => { - if (isPressed.current) return; + return React.useMemo(() => { + if (typeof callback !== "function") { + return {}; + } + const start = (event) => { if (!isMouseEvent(event) && !isTouchEvent(event)) return; if (onStart) { @@ -603,15 +681,12 @@ export function useLongPress( isPressed.current = true; timerId.current = setTimeout(() => { - cbRef.current(event); + callback(event); isLongPressActive.current = true; }, threshold); - }, - [onStart, threshold] - ); + }; - const cancel = React.useCallback( - () => (event) => { + const cancel = (event) => { if (!isMouseEvent(event) && !isTouchEvent(event)) return; if (isLongPressActive.current) { @@ -630,31 +705,24 @@ export function useLongPress( if (timerId.current) { window.clearTimeout(timerId.current); } - }, - [onFinish, onCancel] - ); - - return React.useMemo(() => { - if (callback === null) { - return {}; - } + }; const mouseHandlers = { - onMouseDown: start(), - onMouseUp: cancel(), - onMouseLeave: cancel(), + onMouseDown: start, + onMouseUp: cancel, + onMouseLeave: cancel, }; const touchHandlers = { - onTouchStart: start(), - onTouchEnd: cancel(), + onTouchStart: start, + onTouchEnd: cancel, }; return { ...mouseHandlers, ...touchHandlers, }; - }, [callback, cancel, start]); + }, [callback, threshold, onCancel, onFinish, onStart]); } export function useMap(initialState) { @@ -683,31 +751,35 @@ export function useMap(initialState) { } export function useMeasure() { - const ref = React.useRef(null); - const [rect, setRect] = React.useState({ + const [dimensions, setDimensions] = React.useState({ width: null, height: null, }); - React.useLayoutEffect(() => { - if (!ref.current) return; - - const observer = new ResizeObserver(([entry]) => { - if (entry && entry.contentRect) { - setRect({ - width: entry.contentRect.width, - height: entry.contentRect.height, - }); - } - }); + const previousObserver = React.useRef(null); - observer.observe(ref.current); - return () => { - observer.disconnect(); - }; + const customRef = React.useCallback((node) => { + if (previousObserver.current) { + previousObserver.current.disconnect(); + previousObserver.current = null; + } + + if (node?.nodeType === Node.ELEMENT_NODE) { + const observer = new ResizeObserver(([entry]) => { + if (entry && entry.borderBoxSize) { + const { inlineSize: width, blockSize: height } = + entry.borderBoxSize[0]; + + setDimensions({ width, height }); + } + }); + + observer.observe(node); + previousObserver.current = observer; + } }, []); - return [ref, rect]; + return [customRef, dimensions]; } export function useMediaQuery(query) { @@ -741,7 +813,7 @@ export function useMouse() { elementX: 0, elementY: 0, elementPositionX: 0, - elementPositionY: 0 + elementPositionY: 0, }); const ref = React.useRef(null); @@ -750,18 +822,16 @@ export function useMouse() { const handleMouseMove = (event) => { let newState = { x: event.pageX, - y: event.pageY + y: event.pageY, }; - if (ref.current instanceof HTMLElement) { + if (ref.current?.nodeType === Node.ELEMENT_NODE) { const { left, top } = ref.current.getBoundingClientRect(); - const elementPositionX = left + window.pageXOffset; - const elementPositionY = top + window.pageYOffset; + const elementPositionX = left + window.scrollX; + const elementPositionY = top + window.scrollY; const elementX = event.pageX - elementPositionX; const elementY = event.pageY - elementPositionY; - newState.elementX = elementX; - newState.elementY = elementY; newState.elementX = elementX; newState.elementY = elementY; newState.elementPositionX = elementPositionX; @@ -771,7 +841,7 @@ export function useMouse() { setState((s) => { return { ...s, - ...newState + ...newState, }; }); }; @@ -786,34 +856,44 @@ export function useMouse() { return [state, ref]; } -export function useNetworkState() { - const connection = +const getConnection = () => { + return ( navigator?.connection || navigator?.mozConnection || - navigator?.webkitConnection; + navigator?.webkitConnection + ); +}; - const cache = React.useRef({}); +const useNetworkStateSubscribe = (callback) => { + window.addEventListener("online", callback, { passive: true }); + window.addEventListener("offline", callback, { passive: true }); - const subscribe = React.useCallback((callback) => { - window.addEventListener("online", callback, { passive: true }); - window.addEventListener("offline", callback, { passive: true }); + const connection = getConnection(); + + if (connection) { + connection.addEventListener("change", callback, { passive: true }); + } + + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); if (connection) { - connection.addEventListener("change", callback, { passive: true }); + connection.removeEventListener("change", callback); } + }; +}; - return () => { - window.removeEventListener("online", callback); - window.removeEventListener("offline", callback); +const getNetworkStateServerSnapshot = () => { + throw Error("useNetworkState is a client-only hook"); +}; - if (connection) { - connection.removeEventListener("change", callback); - } - }; - }, []); +export function useNetworkState() { + const cache = React.useRef({}); const getSnapshot = () => { const online = navigator.onLine; + const connection = getConnection(); const nextState = { online, @@ -833,11 +913,11 @@ export function useNetworkState() { } }; - const getServerSnapshot = () => { - throw Error("useNetworkState is a client-only hook"); - }; - - return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return React.useSyncExternalStore( + useNetworkStateSubscribe, + getSnapshot, + getNetworkStateServerSnapshot + ); } export function useObjectState(initialValue) { @@ -848,14 +928,16 @@ export function useObjectState(initialValue) { setState((s) => { const newState = arg(s); - return { - ...s, - ...newState, - }; + if (isPlainObject(newState)) { + return { + ...s, + ...newState, + }; + } }); } - if (typeof arg === "object") { + if (isPlainObject(arg)) { setState((s) => ({ ...s, ...arg, @@ -892,7 +974,7 @@ export function useOrientation() { handleChange(); window.screen.orientation.addEventListener("change", handleChange); } else { - handle_orientationchange() + handle_orientationchange(); window.addEventListener("orientationchange", handle_orientationchange); } @@ -925,17 +1007,23 @@ const getPreferredLanguageServerSnapshot = () => { }; export function usePreferredLanguage() { - return React.useSyncExternalStore(usePreferredLanguageSubscribe, getPreferredLanguageSnapshot, getPreferredLanguageServerSnapshot); + return React.useSyncExternalStore( + usePreferredLanguageSubscribe, + getPreferredLanguageSnapshot, + getPreferredLanguageServerSnapshot + ); } -export function usePrevious(newValue) { - const previousRef = React.useRef(); +export function usePrevious(value) { + const [current, setCurrent] = React.useState(value); + const [previous, setPrevious] = React.useState(null); - React.useEffect(() => { - previousRef.current = newValue; - }); + if (value !== current) { + setPrevious(current); + setCurrent(value); + } - return previousRef.current; + return previous; } export function useQueue(initialValue = []) { @@ -967,6 +1055,7 @@ export function useQueue(initialValue = []) { first: queue[0], last: queue[queue.length - 1], size: queue.length, + queue, }; } @@ -1006,74 +1095,121 @@ export function useRenderInfo(name = "Unknown") { } export function useScript(src, options = {}) { - const [status, setStatus] = React.useState(() => { - if (!src) { - return "idle"; - } - - return "loading"; - }); - - const cachedScriptStatuses = React.useRef({}); + const [status, setStatus] = React.useState("loading"); + const optionsRef = React.useRef(options); React.useEffect(() => { - if (!src) { - return; - } + let script = document.querySelector(`script[src="${src}"]`); - const cachedScriptStatus = cachedScriptStatuses.current[src]; - if (cachedScriptStatus === "ready" || cachedScriptStatus === "error") { - setStatus(cachedScriptStatus); + const domStatus = script?.getAttribute("data-status"); + if (domStatus) { + setStatus(domStatus); return; } - let script = document.querySelector(`script[src="${src}"]`); - - if (script) { - setStatus( - script.getAttribute("data-status") ?? cachedScriptStatus ?? "loading" - ); - } else { + if (script === null) { script = document.createElement("script"); script.src = src; script.async = true; script.setAttribute("data-status", "loading"); document.body.appendChild(script); - const setAttributeFromEvent = (event) => { - const scriptStatus = event.type === "load" ? "ready" : "error"; + const handleScriptLoad = () => { + script.setAttribute("data-status", "ready"); + setStatus("ready"); + removeEventListeners(); + }; - if (script) { - script.setAttribute("data-status", scriptStatus); - } + const handleScriptError = () => { + script.setAttribute("data-status", "error"); + setStatus("error"); + removeEventListeners(); }; - script.addEventListener("load", setAttributeFromEvent); - script.addEventListener("error", setAttributeFromEvent); + const removeEventListeners = () => { + script.removeEventListener("load", handleScriptLoad); + script.removeEventListener("error", handleScriptError); + }; + + script.addEventListener("load", handleScriptLoad); + script.addEventListener("error", handleScriptError); + + const removeOnUnmount = optionsRef.current.removeOnUnmount; + + return () => { + if (removeOnUnmount === true) { + script.remove(); + removeEventListeners(); + } + }; + } else { + setStatus("unknown"); } + }, [src]); - const setStateFromEvent = (event) => { - const newStatus = event.type === "load" ? "ready" : "error"; - setStatus(newStatus); - cachedScriptStatuses.current[src] = newStatus; - }; + return status; +} - script.addEventListener("load", setStateFromEvent); - script.addEventListener("error", setStateFromEvent); +const setSessionStorageItem = (key, value) => { + const stringifiedValue = JSON.stringify(value); + window.sessionStorage.setItem(key, stringifiedValue); + dispatchStorageEvent(key, stringifiedValue); +}; - return () => { - if (script) { - script.removeEventListener("load", setStateFromEvent); - script.removeEventListener("error", setStateFromEvent); - } +const removeSessionStorageItem = (key) => { + window.sessionStorage.removeItem(key); + dispatchStorageEvent(key, null); +}; + +const getSessionStorageItem = (key) => { + return window.sessionStorage.getItem(key); +}; + +const useSessionStorageSubscribe = (callback) => { + window.addEventListener("storage", callback); + return () => window.removeEventListener("storage", callback); +}; + +const getSessionStorageServerSnapshot = () => { + throw Error("useSessionStorage is a client-only hook"); +}; + +export function useSessionStorage(key, initialValue) { + const getSnapshot = () => getSessionStorageItem(key); - if (script && options.removeOnUnmount) { - script.remove(); + const store = React.useSyncExternalStore( + useSessionStorageSubscribe, + getSnapshot, + getSessionStorageServerSnapshot + ); + + const setState = React.useCallback( + (v) => { + try { + const nextState = typeof v === "function" ? v(JSON.parse(store)) : v; + + if (nextState === undefined || nextState === null) { + removeSessionStorageItem(key); + } else { + setSessionStorageItem(key, nextState); + } + } catch (e) { + console.warn(e); } - }; - }, [src, options.removeOnUnmount]); + }, + [key, store] + ); - return status; + React.useEffect(() => { + if ( + getSessionStorageItem(key) === null && + typeof initialValue !== "undefined" + ) { + setSessionStorageItem(key, initialValue); + } + }, [key, initialValue]); + + return [store ? JSON.parse(store) : initialValue, setState]; } export function useSet(values) { @@ -1102,65 +1238,14 @@ export function useSet(values) { return setRef.current; } -export function useSpeech(text, options) { - const [state, setState] = React.useState(() => { - const { lang = "default", name = "" } = options.voice || {}; - return { - isPlaying: false, - status: "init", - lang: options.lang || "default", - voiceInfo: { lang, name }, - rate: options.rate || 1, - pitch: options.pitch || 1, - volume: options.volume || 1, - }; - }); - - const optionsRef = React.useRef(options); - - React.useEffect(() => { - const handlePlay = () => { - setState((s) => { - return { ...s, isPlaying: true, status: "play" }; - }); - }; - - const handlePause = () => { - setState((s) => { - return { ...s, isPlaying: false, status: "pause" }; - }); - }; - - const handleEnd = () => { - setState((s) => { - return { ...s, isPlaying: false, status: "end" }; - }); - }; - - const utterance = new SpeechSynthesisUtterance(text); - optionsRef.current.lang && (utterance.lang = optionsRef.current.lang); - optionsRef.current.voice && (utterance.voice = optionsRef.current.voice); - utterance.rate = optionsRef.current.rate || 1; - utterance.pitch = optionsRef.current.pitch || 1; - utterance.volume = optionsRef.current.volume || 1; - utterance.onstart = handlePlay; - utterance.onpause = handlePause; - utterance.onresume = handlePlay; - utterance.onend = handleEnd; - window.speechSynthesis.speak(utterance); - }, [text]); - - return state; -} - export function useThrottle(value, interval = 500) { const [throttledValue, setThrottledValue] = React.useState(value); - const lastUpdated = React.useRef(); + const lastUpdated = React.useRef(null); React.useEffect(() => { const now = Date.now(); - if (now >= lastUpdated.current + interval) { + if (lastUpdated.current && now >= lastUpdated.current + interval) { lastUpdated.current = now; setThrottledValue(value); } else { @@ -1177,7 +1262,13 @@ export function useThrottle(value, interval = 500) { } export function useToggle(initialValue) { - const [on, setOn] = React.useState(initialValue); + const [on, setOn] = React.useState(() => { + if (typeof initialValue === "boolean") { + return initialValue; + } + + return Boolean(initialValue); + }); const handleToggle = React.useCallback((value) => { if (typeof value === "boolean") { @@ -1190,26 +1281,30 @@ export function useToggle(initialValue) { return [on, handleToggle]; } -export function useVisibilityChange() { - const [documentVisible, setDocumentVisibility] = React.useState(true); +const useVisibilityChangeSubscribe = (callback) => { + document.addEventListener("visibilitychange", callback); - React.useEffect(() => { - const handleChange = () => { - if (document.visibilityState !== "visible") { - setDocumentVisibility(false); - } else { - setDocumentVisibility(true); - } - }; + return () => { + document.removeEventListener("visibilitychange", callback); + }; +}; - document.addEventListener("visibilitychange", handleChange); +const getVisibilityChangeSnapshot = () => { + return document.visibilityState; +}; - return () => { - document.removeEventListener("visibilitychange", handleChange); - }; - }, []); +const getVisibilityChangeServerSnapshot = () => { + throw Error("useVisibilityChange is a client-only hook"); +}; + +export function useVisibilityChange() { + const visibilityState = React.useSyncExternalStore( + useVisibilityChangeSubscribe, + getVisibilityChangeSnapshot, + getVisibilityChangeServerSnapshot + ); - return documentVisible; + return visibilityState === "visible"; } export function useWindowScroll() { @@ -1232,7 +1327,7 @@ export function useWindowScroll() { React.useLayoutEffect(() => { const handleScroll = () => { - setState({ x: window.pageXOffset, y: window.pageYOffset }); + setState({ x: window.scrollX, y: window.scrollY }); }; handleScroll(); diff --git a/package-lock.json b/package-lock.json index 60ea6fa..d920bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "@uidotdev/usehooks", - "version": "2.0.1", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@uidotdev/usehooks", - "version": "2.0.1", + "version": "2.4.1", "license": "MIT", "devDependencies": { + "@types/react": "^18.2.20", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "typescript": "^5.1.6" }, "engines": { "node": ">=16" @@ -20,6 +22,35 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.20", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.20.tgz", + "integrity": "sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -71,6 +102,19 @@ "dependencies": { "loose-envify": "^1.1.0" } + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/package.json b/package.json index f024bff..12908aa 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "@uidotdev/usehooks", - "version": "2.0.1", + "version": "2.4.1", "description": "A collection of modern, server-safe React hooks – from the ui.dev team", "type": "module", "repository": "uidotdev/usehooks", "devDependencies": { + "@types/react": "^18.2.20", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "typescript": "^5.1.6" }, "exports": { "default": "./index.js" @@ -15,8 +17,10 @@ "node": ">=16" }, "files": [ - "index.js" + "index.js", + "index.d.ts" ], + "types": "index.d.ts", "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7f6bc03 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noImplicitAny": true, + "allowJs": true, + "outDir": "./irrelevant/unused" + } +} diff --git a/usehooks.com/public/img/banner-sale-reactgg.svg b/usehooks.com/public/img/banner-sale-reactgg.svg new file mode 100644 index 0000000..9aee0dd --- /dev/null +++ b/usehooks.com/public/img/banner-sale-reactgg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/usehooks.com/public/img/bytes-tshirt.png b/usehooks.com/public/img/bytes-tshirt.png new file mode 100644 index 0000000..5f59d9c Binary files /dev/null and b/usehooks.com/public/img/bytes-tshirt.png differ diff --git a/usehooks.com/public/img/react-gg-logo-sticker.svg b/usehooks.com/public/img/react-gg-logo-sticker.svg new file mode 100644 index 0000000..3e6f58a --- /dev/null +++ b/usehooks.com/public/img/react-gg-logo-sticker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/usehooks.com/src/components/CountdownTimer.tsx b/usehooks.com/src/components/CountdownTimer.tsx new file mode 100644 index 0000000..72ea4d4 --- /dev/null +++ b/usehooks.com/src/components/CountdownTimer.tsx @@ -0,0 +1,89 @@ +import { Fragment, useEffect, useState } from "react"; + +interface CountdownProps { + targetDate: string; // YYYY-MM-DD format +} + +interface TimeLeft { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +function calculateTimeLeft(targetDate: string): TimeLeft { + const target = new Date(`${targetDate}T00:00:00-08:00`); + const now = new Date(); + const difference = +target - +now; + + if (difference <= 0) { + return { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }; + } + + return { + days: Math.floor(difference / (1000 * 60 * 60 * 24)), + hours: Math.floor((difference / (1000 * 60 * 60)) % 24), + minutes: Math.floor((difference / 1000 / 60) % 60), + seconds: Math.floor((difference / 1000) % 60), + }; +} + +const formatNumber = (number: number) => number.toString().padStart(2, "0"); + +const Countdown: React.FC = ({ targetDate }) => { + const [timeLeft, setTimeLeft] = useState( + calculateTimeLeft(targetDate) + ); + + useEffect(() => { + const timer = setInterval(() => { + const newTimeLeft = calculateTimeLeft(targetDate); + setTimeLeft(newTimeLeft); + if ( + newTimeLeft.days === 0 && + newTimeLeft.hours === 0 && + newTimeLeft.minutes === 0 && + newTimeLeft.seconds === 0 + ) { + clearInterval(timer); + } + }, 1000); + + return () => clearInterval(timer); + }, [targetDate]); + + if ( + timeLeft.days === 0 && + timeLeft.hours === 0 && + timeLeft.minutes === 0 && + timeLeft.seconds === 0 + ) { + return null; + } + + return ( +
+ {["days", "hours", "minutes", "seconds"].map((unit, index) => ( + + {index > 0 && :} +
+ + {formatNumber(timeLeft[unit as keyof TimeLeft]).charAt(0)} + + + {formatNumber(timeLeft[unit as keyof TimeLeft]).charAt(1)} + +

{unit}

+
+
+ ))} +
+ ); +}; + +export default Countdown; diff --git a/usehooks.com/src/components/HookDescription.astro b/usehooks.com/src/components/HookDescription.astro index 0206b02..3ca4182 100644 --- a/usehooks.com/src/components/HookDescription.astro +++ b/usehooks.com/src/components/HookDescription.astro @@ -16,11 +16,15 @@ const { name } = Astro.props; class="logo" alt="React.gg" /> -

Want to learn how to build {name} yourself? Check out react.gg – the interactive way to master modern React.

+

+ Want to learn how to build {name} yourself? Check out react.gg – the interactive way to master modern React. +

- {isOpen && ( - - -

Modal

-

This is a modal triggered by a long press.

-
- )} +

useMeasure

+

(Resize the rulers)

+ ); } + ``` diff --git a/usehooks.com/src/content/hooks/useScript.mdx b/usehooks.com/src/content/hooks/useScript.mdx index d11fa79..7a6d81a 100644 --- a/usehooks.com/src/content/hooks/useScript.mdx +++ b/usehooks.com/src/content/hooks/useScript.mdx @@ -17,8 +17,7 @@ import StaticCodeContainer from "../../components/StaticCodeContainer.astro"; scripts into a React component. It manages the loading and status of the script, allowing you to conditionally render components or perform actions based on whether the script has been successfully loaded or encountered an - error. The hook keeps track of the script’s status, such as "loading," - "ready," or "error," and provides this status as a return value. Additionally, + error. The hook keeps track of the script’s status and provides this status as a return value. Additionally, it offers options to remove the script when the component is unmounted, ensuring proper cleanup. @@ -38,7 +37,7 @@ import StaticCodeContainer from "../../components/StaticCodeContainer.astro";
| Name | Type | Description | | ------ | ------ | ----------- | - | status | string | This represents the status of the script load. Possible values are: `idle`, `loading`, `ready`, and `error`. | + | status | string | This represents the status of the script load, `loading`, `ready`, `error`, or `unknown`. An `unknown` script is one that previously exists in the document, but was not added via `useScript`. |
diff --git a/usehooks.com/src/content/hooks/useSessionStorage.mdx b/usehooks.com/src/content/hooks/useSessionStorage.mdx index 10208c1..0405fa9 100644 --- a/usehooks.com/src/content/hooks/useSessionStorage.mdx +++ b/usehooks.com/src/content/hooks/useSessionStorage.mdx @@ -1,6 +1,5 @@ --- name: useSessionStorage -experimental: true rank: 9 tagline: Store, retrieve, and synchronize data from the browser’s session storage with useSessionStorage. sandboxId: usesessionstorage-yoodu4 diff --git a/usehooks.com/src/layouts/Layout.astro b/usehooks.com/src/layouts/Layout.astro index eab3712..ba8d9f6 100644 --- a/usehooks.com/src/layouts/Layout.astro +++ b/usehooks.com/src/layouts/Layout.astro @@ -13,12 +13,12 @@ const { ogImage = new URL("/meta/og.jpg", Astro.url), } = Astro.props; -const pathname = Astro.url.pathname - -const url = pathname[pathname.length - 1] === "/" - ? new URL(pathname.slice(0, pathname.length -1), Astro.site) - : new URL(Astro.url.pathname, Astro.site) +const pathname = Astro.url.pathname; +const url = + pathname[pathname.length - 1] === "/" + ? new URL(pathname.slice(0, pathname.length - 1), Astro.site) + : new URL(Astro.url.pathname, Astro.site); --- @@ -90,6 +90,34 @@ const url = pathname[pathname.length - 1] === "/" data-api="/stats/api/event" data-domain="usehooks.com" > + + diff --git a/usehooks.com/src/sections/Footer.astro b/usehooks.com/src/sections/Footer.astro index c6ce3dc..cde897a 100644 --- a/usehooks.com/src/sections/Footer.astro +++ b/usehooks.com/src/sections/Footer.astro @@ -2,57 +2,59 @@ --- diff --git a/usehooks.com/src/styles/globals.css b/usehooks.com/src/styles/globals.css index 8ee6d8a..5a78f2a 100644 --- a/usehooks.com/src/styles/globals.css +++ b/usehooks.com/src/styles/globals.css @@ -152,7 +152,7 @@ video { --brand-pink: #f38ba3; --brand-green: #0ba95b; --brand-purple: #7b5ea7; - --brand-biege: #f9f4da; + --brand-beige: #f9f4da; --brand-blue: #12b5e5; --brand-orange: #fc7428; --brand-red: #ed203d; @@ -161,7 +161,7 @@ video { --magesticPurple: #9d7dce; --red: var(--brand-red); - --white: var(--brand-biege); + --white: var(--brand-beige); --purple: var(--brand-purple); --black: var(--brand-coal); --blue: var(--brand-blue); @@ -248,7 +248,6 @@ h2 { font-size: var(--font-md); } - small { font-size: var(--font-sm); }