@exabytellc/utils
Version:
EB react utils to make everything a little easier!
589 lines (554 loc) • 20.3 kB
JavaScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Cookie, LocalStorage, SessionStorage } from "../Storage";
import { isFunc, isObj } from "../types";
/**
* Creates a custom hook with an optional initializer function.
*
* This function generates a hook (`useHook`) that can accept and return a context value,
* which can be customized by providing additional props through `withProps`.
*
* @param {Function} [initializer=() => {}] - A function to initialize the state with given parameters.
* @returns {Object} - The custom hook object with `useHook` and `withProps` for additional customization.
*/
export function createStateHook(initializer = () => { }) {
/**
* Custom hook to access and manipulate the state context.
*
* @param {*} state - The current state to be used by the initializer function.
* @param {Object} [props] - Optional props to customize the state.
* @returns {*} - The initialized context value based on the provided state and props.
* @throws {Error} - If the hook is used outside of a context provider.
*/
const useHook = ([value, setValue, _obj = {}], props) => {
return [value, setValue, { ..._obj, ...(initializer([value, setValue], props) ?? {}) }];
};
/**
* Higher-order function to enhance the context with additional properties.
*
* @param {Object} props - Additional properties for the context, extending the `useHook` functionality.
* @returns {Function} - Returns the modified hook with additional context props.
*/
const withProps = (props) => {
useHook.props = props;
return useHook;
};
// Attach the `withProps` function to the `useHook` to allow additional customization.
useHook.withProps = withProps;
return useHook;
}
/**
* A custom hook to apply multiple hooks with optional props.
*
* @param {any} initialState - The initial state value.
* @param {Array} hooks - An array of hooks to apply, where each hook is a function.
* @returns {Array} - The current state, a function to set the state, and an object containing each hook's methods.
*/
export function StateHooks(initialValue, hooks = []) {
const [value, setValue] = useState(initialValue);
return hooks.reduce((prev, hook) => hook(prev, hook?.props), [value, setValue]);
}
/**
* A custom hook that provides toggle and reset functionality.
*
* @param {[boolean, Function]} param0 - The current value and setter function.
* @param {Object} [_obj={}] - Optional additional object to spread into the return.
* @returns {[boolean, Function, Object]} The current value, setter function, and toggle/reset methods.
*/
export const ToggleState = createStateHook(([value, setValue]) => {
const initial = useRef(value);
const toggle = useCallback(() => {
setValue((prev) => !prev);
}, [setValue]);
const reset = useCallback(() => {
setValue(initial.current);
}, [setValue]);
return { toggle, reset };
});
/**
* A custom hook that extends ToggleState to manage modal open/close state.
*
* @param {[boolean, Function]} param0 - The current value and setter function.
* @param {Object} [_obj={}] - Optional additional object to spread into the return.
* @returns {[boolean, Function, Object]} The current value, setter function, and modal methods.
*/
export const ModalState = createStateHook(([value, setValue]) => {
const [, , { toggle, reset }] = ToggleState([value, setValue]);
const open = useCallback(() => {
setValue(true);
}, [setValue]);
const close = useCallback(() => {
setValue(false);
}, [setValue]);
return { toggle, reset, open, close };
});
/**
* A custom hook managing state stored in a cookie.
*
* @param {[any, Function]} param0 - The current value and setter function.
* @param {Object} options - Cookie configuration options.
* @param {string} options.name - The name of the cookie.
* @param {number} [options.expireHours=null] - Expiration time in hours.
* @param {boolean} [options.json=false] - Flag indicating if the value is JSON.
* @returns {[any, Function, Object]} An array where:
* - The first element is the cookie-stored state.
* - The second element is the setter function.
* - The third element is an object with save, refresh, and additional cookie methods.
*/
export const CookieState = createStateHook(([value, setValue], { name, expireHours = null, json = false } = {}) => {
const initial = useRef(value);
const save = useCallback(
(value) => {
if (value) {
Cookie.set(name, value, { expireHours, json });
} else {
Cookie.del(name);
}
},
[name, expireHours, json]
);
const refresh = useCallback(() => {
setValue(value);
}, [setValue, value]);
// initial value
useLayoutEffect(() => {
setValue(() => Cookie.get(name, { json }) ?? initial.current);
}, [json, name]);
// save onchange
useLayoutEffect(() => {
save(value);
}, [value, save]);
// check cookie expired?
useEffect(() => {
if (value && value != initial.current) {
const interval = setInterval(() => {
if (!Cookie.get(name)) setValue(initial.current)
}, expireHours * 3600000 + 1000);
return () => clearInterval(interval);
}
}, [expireHours, name, setValue, value]);
return { save, refresh };
});
/**
* A custom hook that manages local storage state.
*
* @param {[any, Function]} param0 - The current value and setter function.
* @param {Object} options - Configuration options for local storage.
* @param {string} options.name - The name of the local storage key.
* @param {boolean} [options.json=false] - Flag to indicate JSON parsing.
* @returns {[any, Function, Object]} The current value, setter function, and save method.
*/
export const LocalStorageState = createStateHook(([value, setValue], { name, json = false } = {}) => {
const initial = useRef(value);
useLayoutEffect(() => {
setValue(() => LocalStorage.get(name, { json }) ?? initial.current);
}, [json, name]);
const save = useCallback(
(value) => {
if (value) {
LocalStorage.set(name, value, { json });
} else {
LocalStorage.del(name);
}
},
[name, json]
);
useLayoutEffect(() => {
save(value);
}, [value, save]);
return { save };
});
/**
* A custom hook that manages session storage state.
*
* @param {[any, Function]} param0 - The current value and setter function.
* @param {Object} options - Configuration options for session storage.
* @param {string} options.name - The name of the session storage key.
* @param {boolean} [options.json=false] - Flag to indicate JSON parsing.
* @returns {[any, Function, Object]} The current value, setter function, and save method.
*/
export const SessionStorageState = createStateHook(([value, setValue], { name, json = false } = {}) => {
const initial = useRef(value);
useLayoutEffect(() => {
setValue(() => SessionStorage.get(name, { json }) ?? initial.current);
}, [json, name]);
const save = useCallback(
(value) => {
if (value) {
SessionStorage.set(name, value, { json });
} else {
SessionStorage.del(name);
}
},
[name, json]
);
useLayoutEffect(() => {
save(value);
}, [value, save]);
return { save };
});
/**
* A custom hook that manages a list with various utility methods.
*
* @param {[Array, Function]} param0 - The current value and setter function.
* @param {Object} [_obj={}] - Optional additional object to spread into the return.
* @returns {[Array, Function, Object]} The current value, setter function, and list utility methods.
*/
export const ListState = createStateHook(([value, setValue]) => {
const indexWhere = useCallback(
(where) => value.findIndex(where),
[value]
);
const findWhere = useCallback(
(where) => value.find(where),
[value]
);
const add = useCallback(
(item) => {
setValue((p) => [...p, (isFunc(item) ? item(p) : item)]);
},
[setValue]
);
const addAfter = useCallback(
(item, index) => {
setValue((prev) => [
...prev.slice(0, index + 1),
isFunc(item) ? item(prev) : item,
...prev.slice(index + 1),
]);
},
[setValue]
);
const update = useCallback(
(item, i) => {
setValue((p) => {
const v = [...p];
if (v?.[i]) v[i] = (isFunc(item) ? item(p) : item);
return v;
});
},
[setValue]
);
const updateWhere = useCallback(
(item, where) => {
const i = indexWhere(where);
if (i > -1) update(item, i);
},
[indexWhere, update]
);
const remove = useCallback(
(i) => {
setValue((p) => {
const v = [...p];
v.splice(i, 1);
return v;
});
},
[setValue]
);
const removeWhere = useCallback(
(where) => {
const i = indexWhere(where);
if (i > -1) remove(i);
},
[indexWhere, remove]
);
return { indexWhere, findWhere, add, addAfter, update, updateWhere, remove, removeWhere };
});
/**
* A custom hook that manages a map with various utility methods.
*
* @param {[Object, Function]} param0 - The current value and setter function.
* @param {Object} [_obj={}] - Optional additional object to spread into the return.
* @returns {[Object, Function, Object]} The current value, setter function, and map utility methods.
*/
export const MapState = createStateHook(([value, setValue]) => {
const keys = useMemo(() => Object.keys(value), [value]);
const values = useMemo(() => Object.values(value), [value]);
const keyWhere = useCallback(
(where) => keys?.[values.findIndex(where)],
[keys, values]
);
const findWhere = useCallback(
(where) => values.find(where),
[values]
);
const set = useCallback(
(key, item) => {
setValue((p) => ({ ...p, [key]: (isFunc(item) ? item(p) : item) }));
},
[setValue]
);
const update = useCallback(
(item, key) => {
setValue((p) => {
const v = { ...p };
if (v?.[key]) v[key] = (isFunc(item) ? item(p) : item);
return v;
});
},
[setValue]
);
const updateWhere = useCallback(
(item, where) => {
const key = keyWhere(where);
if (key) update(item, key);
},
[keyWhere, update]
);
const remove = useCallback(
(key) => {
setValue((p) => {
const v = { ...p };
if (v?.[key]) delete v[key];
return v;
});
},
[setValue]
);
const removeWhere = useCallback(
(where) => {
const key = keyWhere(where);
if (key) remove(key);
},
[keyWhere, remove]
);
return { keys, values, keyWhere, findWhere, set, update, updateWhere, remove, removeWhere };
});
/**
* A custom hook that manages a list of maps with various utility methods.
*
* @param {[Object, Function]} param0 - The current value and setter function.
* @param {Object} [_obj={}] - Optional additional object to spread into the return.
* @returns {[Object, Function, Object]} The current value, setter function, and map utility methods.
*/
export const DataState = createStateHook(([value, setValue]) => {
const keys = useMemo(() => Object.keys(value), [value]);
const values = useMemo(() => Object.values(value), [value]);
const keyWhere = useCallback(
(where) => keys?.[values.findIndex(where)],
[keys, values]
);
const findWhere = useCallback(
(where) => values.find(where),
[values]
);
const set = useCallback(
(key, item) => {
setValue((p) => ({ ...p, [key]: (isFunc(item) ? item(p) : item) }));
},
[setValue]
);
const update = useCallback(
(item, key) => {
setValue((p) => {
const v = { ...p };
if (v?.[key]) v[key] = (isFunc(item) ? item(p) : item);
return v;
});
},
[setValue]
);
const updateWhere = useCallback(
(item, where) => {
const key = keyWhere(where);
if (key) update(item, key);
},
[keyWhere, update]
);
const remove = useCallback(
(key) => {
setValue((p) => {
const v = { ...p };
if (v?.[key]) delete v[key];
return v;
});
},
[setValue]
);
const removeWhere = useCallback(
(where) => {
const key = keyWhere(where);
if (key) remove(key);
},
[keyWhere, remove]
);
return { keys, values, keyWhere, findWhere, set, update, updateWhere, remove, removeWhere };
});
/**
* A custom hook that provides a debounced setter function for a state value.
*
* @param {[any, Function, Object]} param0 - An array containing the current value, setter function, and optional object.
* @returns {[any, Function, Object]} - The current value, setter function, and an object containing the debounced setter.
*/
export const DebounceState = createStateHook(([, setValue]) => {
// timeout
const timeout = useRef(null);
/**
* Sets the value with a debounce delay.
*
* @param {any} val - The value to set after the delay.
* @param {number} [delay=1000] - The debounce delay in milliseconds.
*/
const setDebounced = useCallback(
(val, delay = 1000) => {
clearTimeout(timeout.current);
timeout.current = setTimeout(() => setValue((prev) => (isFunc(val) ? val(prev) : val)), delay);
},
[setValue]
);
return { setDebounced };
});
/**
* A custom hook that monitors a media query and updates the state based on its match status.
*
* @param {[boolean, Function, Object]} param0 - An array containing the current match status, setter function, and optional object.
* @param {Object} options - An object containing the media query string.
* @param {string} options.query - The media query to listen for changes.
* @returns {[boolean, Function, Object]} - The current match status, setter function, and optional object.
*/
export const MediaQueryState = createStateHook(([, setValue], { query }) => {
useEffect(() => {
const media = window.matchMedia(query);
setValue(media.matches); // Set initial value based on the media query match status
const listener = () => setValue(media.matches); // Update state when media query status changes
media.addEventListener("change", listener); // Listen for changes in media query match status
// Cleanup function to remove the listener
return () => {
media.removeEventListener("change", listener);
};
}, [query]);
});
/**
* A custom hook that monitors multiple media queries and updates the state based on their match status.
*
* @param {[Object, Function]} param0 - A tuple containing the current match status object and the state setter function.
* @param {Object} options - Options containing the media queries to monitor.
* @param {Object} options.queries - An object where keys are query names and values are media query strings.
* @returns {Object} - Returns an object with:
* - `ifQuery`: A function to check if a specific media query matches.
* - `ifQueries`: A function to check if multiple media queries match based on a logical condition.
* - `breakpointValue`: A function to return a value based on the current active query with fallback to the previous one.
*
* @example
* // Usage example
* const [matches, setMatches, { ifQuery, ifQueries, breakpointValue }] = MediaQueriesState(useState({}),{
* queries: {
* mobile: "(max-width: 600px)",
* tablet: "(min-width: 601px) and (max-width: 960px)",
* desktop: "(min-width: 961px)"
* }
* });
*
* // Check if a specific query matches
* const isMobile = ifQuery("mobile"); // true or false
*
* // Check if multiple queries match
* const isMobileOrTablet = ifQueries(["mobile", "tablet"]); // true if either matches
* const isDesktopOnly = ifQueries(["desktop"], "AND"); // true if desktop matches
*
* const width = breakpointValue(100, null, 800); // will return value of match or fallback to non null smaller value
* const height = breakpointValue({mobile: 100, desktop: 300}); // will return value of match or fallback to non null smaller value
*
*/
export const MediaQueriesState = createStateHook(([value, setValue], { queries }) => {
useEffect(() => {
const media = {};
/**
* Updates the state with the current match status for all media queries.
*/
const updateMatches = () => {
const newMatches = {};
let changed = false;
for (const key in media) {
const match = media[key].matches;
newMatches[key] = match;
if (value?.[key] !== match) {
changed = true;
}
}
if (changed) {
setValue(newMatches);
}
};
// Initialize media query listeners
for (const key in queries) {
const query = queries[key];
media[key] = window.matchMedia(query);
media[key].addEventListener("change", updateMatches);
}
// Set initial match status
updateMatches();
// Cleanup function to remove listeners
return () => {
for (const key in media) {
media[key].removeEventListener("change", updateMatches);
}
};
}, [queries, setValue, value]);
/**
* Checks if a specific media query currently matches.
*
* @param {string} query - The name of the query to check (must be a key from the `queries` object).
* @returns {boolean} - Returns `true` if the media query matches, otherwise `false`.
*/
const ifQuery = useCallback((query) => value?.[query]?.matches, [value]);
/**
* Checks if multiple media queries match based on a logical condition.
*
* @param {string[]} queries - An array of query names to check.
* @param {"AND"|"OR"} [logic="OR"] - The logical operator to apply:
* - `"OR"` (default): Returns `true` if at least one query matches.
* - `"AND"`: Returns `true` only if all queries match.
* @returns {boolean} - The result of applying the logical operator to the match results.
*/
const ifQueries = useCallback(
(queries, logic = "OR") => {
if (!Array.isArray(queries)) {
console.error("ifQueries expects an array of query names.");
return false;
}
if (!value) return false;
if (logic === "AND") {
return queries.every((query) => value?.[query]?.matches);
}
// Default is "OR"
return queries.some((query) => value?.[query]?.matches);
},
[value]
);
/**
* Resolves the most appropriate value based on the largest matching media query.
*
* Iterates from the largest to smallest breakpoint (based on the order in `queries`)
* and returns the first non-null corresponding value passed in `args`.
*
* @param {...*} args - A prioritized list of values corresponding to each media query in order,
* from smallest to largest (e.g., [xsValue, smValue, mdValue, ...]).
* Use `null` to skip a value for a specific breakpoint.
* @returns {*} - The value corresponding to the largest matching media query, or the next best fallback.
*/
const breakpointValue = useCallback(
(...args) => {
if (isObj(args?.[0])) {
const obj = args?.[0];
const queryKeys = Object.keys(queries);
args = queryKeys.map((key) => obj?.[key] ?? null);
}
if (!value) return args.find((v) => v != null);
const queryKeys = Object.keys(queries);
for (let i = queryKeys.length - 1; i >= 0; i--) {
const key = queryKeys[i];
if (value[key]) {
if (args[i] != null) return args[i];
// Fallback to earlier non-null value
for (let j = i - 1; j >= 0; j--) {
if (args[j] != null) return args[j];
}
}
}
return null
},
[value, queries]
);
return { ifQuery, ifQueries, breakpointValue };
});