UNPKG

@coveord/plasma-mantine

Version:

A Plasma flavoured Mantine theme

154 lines (139 loc) 6.59 kB
import {Dispatch, SetStateAction, useMemo, useState} from 'react'; /** * A search param entry defines the encoded value of a search parameter as `[key, value, alwaysEmit?]`. * The third entry is an optional boolean that defaults to `false`. * Setting `alwaysEmit` to `true` means any non-nullish value is always written to the search params, * even if it matches the initial value. It is also written on initialization. */ export type SearchParamEntry = [string, string | null | undefined, boolean?]; /** A URL split into an array of length 4, as [pathname, search, hash, hashSearch] */ type UrlParts = [string, string, string, string]; const slice = Function.prototype.call.bind(Array.prototype.slice) as <T>( from: ArrayLike<T>, start?: number, end?: number, ) => T[]; /** * Split a url into its parts. * * @param href The url to extract the parts from. * @returns The separate parts, all are an empty string if not present. */ const extractParts = (href: string) => slice(/^([^?#]*)(\?[^#]*|)(#[^?]*|)(\?.*|)$/.exec(href ?? ''), 1, 5) as UrlParts; /** * The index of the search parameter to use, e.g. hashSearch for hash routes (hash starts with '#/'). * * @param parts: The url parts, as returned by `extractParts`. * @returns The index of the search parameter to use (1 or 3). */ const searchIndex = (parts: UrlParts): 1 | 3 => (/^#\//.test(parts[2]) ? 3 : 1); /** * Read the **current** search params from `window.location`, with support for detecting React's HashRouter. * Also returns a method that will yield the href (string) value, after any changes made on the params object. * * @returns The `URLSearchParams` instance, and a function that can be used to get an updated href. */ const getSearchParams = (): URLSearchParams => { const parts = extractParts(window.location.href); return new URLSearchParams(parts[searchIndex(parts)]); }; /** * Apply the search params to the current location, using `replaceState` (no navigation history). * Note that only parameters in the `params` argument will be set, any other current params will be removed. * * @param params The parameters to apply. */ const applySearchParams = (params: URLSearchParams): void => { const currentHref = window.location.href; const parts = extractParts(currentHref); const search = params.size > 0 ? `?${params.toString()}` : ''; const index = searchIndex(parts); if (parts[index] !== search) { parts[index] = search; window.history.replaceState(null, '', parts.join('')); } }; export interface UseUrlSyncedStateOptions<T> { /** * The initial state to use, if there would be no search params to deserialize from. * These values are also treated as defaults, and if the current state matches the initialState, * no value will be written to the search params. */ initialState: T | (() => T); /** * The serializer function is used to determine how the state is translated to url search parameters. * Called each time the state changes. * Note that the serializer should always return entries for keys it controls, also if the current value is "unset" (`null` or empty). * This ensures params get removed from the search when they are being unset. * * @param stateValue The new state value to serialize. * @returns An iterable of `[key, value]` to set as url search parameters. * @example (filterValue) => [['filter', filterValue]] // ?filter=filterValue */ serializer: (stateValue: T) => Iterable<SearchParamEntry>; /** * The deserializer function is used to determine how the url parameters influence the initial state. * May return a partial state, values that are not deserialed are taken from the `initialState`. * Called only once when initializing the state. * @param params All the search parameters of the current url. * @param initialState The initialState, can be used to take defaults from. * @returns The initial state based on the current url. * @example (params) => params.get('filter') ?? '', */ deserializer: (params: URLSearchParams, initialState: T) => T; /** * Whether the state should be synced with the url, defaults to `true`. * When set to `false`, the hook behaves just like a regular `useState` hook from react. */ sync?: boolean; } const getInitialState = <T>(options: UseUrlSyncedStateOptions<T>): T => options.initialState instanceof Function ? options.initialState() : options.initialState; export const useUrlSyncedState = <T>(options: UseUrlSyncedStateOptions<T>) => { const sync = options.sync !== false; const [state, setState] = useState<T>(() => { const initialState = getInitialState(options); return sync ? options.deserializer(getSearchParams(), initialState) : initialState; }); // Capture the initial state as a map (first render only!), to compare values and see if they should be set to the params. const initialStateSerialized = useMemo(() => { const stateMap = new Map<string, string>(); let initialize: URLSearchParams | null = null; let needsApply = false; for (const [key, value, alwaysEmit] of options.serializer(getInitialState(options))) { stateMap.set(key, value); if (sync && alwaysEmit && value) { initialize ??= getSearchParams(); if (!initialize.has(key)) { needsApply = true; initialize.set(key, value); } } } if (needsApply) { applySearchParams(initialize); } return stateMap; }, []); const enhancedSetState = useMemo<Dispatch<SetStateAction<T>>>(() => { if (!sync) { return setState; } return (updater: SetStateAction<T>) => { setState((old) => { const newValue = updater instanceof Function ? updater(old) : updater; const search = getSearchParams(); for (const [key, value, alwaysEmit] of options.serializer(newValue)) { if (value && (alwaysEmit || !Object.is(initialStateSerialized.get(key), value))) { search.set(key, value); } else { search.delete(key); } } applySearchParams(search); return newValue; }); }; }, [sync]); return [state, enhancedSetState] as const; };