UNPKG

@pastable/react

Version:
200 lines (162 loc) 8.03 kB
import { SetStateAction, useEffect } from "react"; import { ObjectLiteral } from "@pastable/typings"; import { Formater, format, isBrowser, isDefined, isType } from "@pastable/utils"; import { useEvent } from "./useEvent"; import { useForceUpdate } from "./useForceUpdate"; /** Get/set page history with query params */ export const useQueryParams = <QP = ObjectLiteral>(props: UseQueryParamsProps<QP> = {}) => { const { getterFormater, setterFormater, toPath } = props; const state = useCurrentQueryParams<QP>(getterFormater as any); const setter = useSetQueryParams<QP>({ formater: setterFormater as any, toPath }); const reset = (keys?: Array<keyof QP>) => setter(Object.fromEntries((keys || Object.keys(state)).map((key: any) => [key, undefined]))); // Set default values for keys not yet in query params useEffect(() => { if (!props.defaultValues) return; setter({ ...props.defaultValues, ...state }); }, []); return [state, setter, reset] as const; }; export interface UseQueryParamsProps<QP = ObjectLiteral> extends Pick<UseSetQueryParamsProps, "toPath"> { /** Getter used to parse query params as object from query string */ getterFormater?: Formater<QP[keyof QP], any, keyof QP>; /** Setter used to serialize query string from query param object */ setterFormater?: Formater<QP[keyof QP], any, keyof QP>; /** Set default values for keys not yet in query params */ defaultValues?: QP; } export const getLocation = () => (isBrowser() ? window.location : ({ search: {} } as Location)); /** Get parsed query params, might be formated using given method */ export const useCurrentQueryParams = <QP = ObjectLiteral, F extends Formater = Formater>(formater?: F) => { const location = getLocation(); const params = new URLSearchParams(location.search); const parsed = decodeValues(Object.fromEntries(params.entries())); const forceUpdate = useForceUpdate(); useEvent("pushstate" as any, forceUpdate); const formated = formater ? format(parsed, formater) : parsed; return formated as Partial<QP>; }; /** Control a queryParam from its key like a useState */ export const useQueryParamsState = <Value, Key extends string = any, QP = any>( key: Key, props: UseQueryParamsStateProps<Value, Key, QP> = {} ) => { const { getterFormater, setterFormater, toPath, defaultValue } = props; const state = useCurrentQueryParams<QP>(getterFormater as any); const setter = useSetQueryParams({ formater: setterFormater as any, toPath }); const queryParam = state[(key as any) as keyof QP] || defaultValue; const setQueryParam = (action: SetStateAction<Value>) => { const current = isType<Function>(action, typeof action === "function") ? action(queryParam as any) : action; setter({ [key]: current }); }; return [queryParam as Value, setQueryParam] as undefined extends QP ? [Value, UseQueryParamsSetState<Value>] : [QP[Key extends keyof QP ? Key : never], UseQueryParamsSetState<QP[Key extends keyof QP ? Key : never]>]; // as UseQueryParamsState<QP[keyof QP]> }; export interface UseQueryParamsStateProps<Value, K, QP = ObjectLiteral> extends Pick<UseSetQueryParamsProps, "toPath"> { /** Getter used to parse query params as object from query string */ getterFormater?: Formater<QP[K extends keyof QP ? K : never], any, K>; /** Setter used to serialize query string from query param object */ setterFormater?: Formater<QP[K extends keyof QP ? K : never], any, K>; /** Set default values for keys not yet in query params */ defaultValue?: Value | QP[K extends keyof QP ? K : never]; } export type UseQueryParamsSetState<T = any> = (action: SetStateAction<T>) => void; export type UseQueryParamsState<T = any> = [T, UseQueryParamsSetState<T>]; const noop = () => {}; export const getHistory = () => isBrowser() ? window.history : (({ pushState: noop, replaceState: noop } as any) as History); export type HistoryMode = "push" | "replace"; /** Update page history by merging current queryParams with values */ export const useSetQueryParams = <QP = ObjectLiteral>({ toPath: toPathProp, formater, }: UseSetQueryParamsProps = {}) => { const merger = useQueryParamsMerger<typeof formater, QP>(formater); const setter = (values: Partial<QP>, mode: HistoryMode = "push", toPath?: UseSetQueryParamsProps["toPath"]) => { const history = getHistory(); const location = getLocation(); const pathname = location.pathname; const basePath = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; const path = toPath || toPathProp; // Either pass a static toPath or a function that will be given basePath as argument const to = path ? (typeof path === "string" ? path : path(basePath)) : basePath; const url = to + merger(values); if (mode === "push") { history.pushState(values, "", url); } else { history.replaceState(values, "", url); } }; return setter; }; export type UseSetQueryParamsReturn<QP = ObjectLiteral> = ({ toPath: toPathProp, formater, }?: UseSetQueryParamsProps) => ( values: Partial<QP>, mode?: HistoryMode, toPath?: UseSetQueryParamsProps["toPath"] ) => void; export type UseSetQueryParamsToPathFn = (currentPathname: string) => string; export interface UseSetQueryParamsProps { /** * Allow overriding the pathname on which the history will be pushed/replaced, * defaults to current history.location.pathname * Either pass a static toPath or a function that will be given basePath as argument */ toPath?: string | UseSetQueryParamsToPathFn; /** Custom formater fn to be passed to useQueryParamsMerger */ formater?: Formater; } /** Merge current queryParams with values and return the resulting query string */ export const useQueryParamsMerger = <F extends Function = Formater, QP = ObjectLiteral>(customFormater?: F) => { const params = useCurrentQueryParams(); return (values: Partial<QP>) => formatObjToQueryString({ ...params, ...values }, customFormater); }; const decodeValues = <T = ObjectLiteral>(obj: T) => format(obj, decodeURIComponent); /** Remove object keys if not valid as query params */ export const formatObjToQueryString = <Value = ObjectLiteral, F extends Function = Formater>( obj: Value, customFormater?: F ) => "?" + Object.entries(obj) .map(([key, value]) => [key, formatValueToQueryParam(value, customFormater, key)]) .filter(([key, value]) => isValidQueryParam(value, key)) .map(([key, value]) => `${key}=${decodeURIComponent(value)}`) .join("&"); function isValidQueryParam<Value = any>(value: Value, _key: string) { if (Array.isArray(value)) return value.length; if (typeof value === "object") return value instanceof Date; return isDefined(value); } function formatValueToQueryParam<Value = any, F extends Function = Formater>( rawValue: Value, customFormater: F | null, key: string ) { const value = customFormater?.(rawValue, key) || rawValue; return value; } // Taken from https://github.com/streamich/react-use/blob/8ceb4c0f0c5625124f487b435a2fd0d3b3bc2a4f/src/useLocation.this // Props to him function patchHistoryMethod(method: "pushState" | "replaceState") { const history = window.history; const original = history[method]; history[method] = function (state) { const result = original.apply(this, arguments); const event = new Event(method.toLowerCase()); (event as any).state = state; window.dispatchEvent(event); return result; }; } if (isBrowser()) { // if (isBrowser() && (import.meta as any).hot.data?.patchState !== "patched") { // console.log("patching"); patchHistoryMethod("pushState"); patchHistoryMethod("replaceState"); // (import.meta as any).hot.data.patchState = "patched"; }