13
common/resources/client/utils/hooks/is-mobile-device.ts
Executable file
13
common/resources/client/utils/hooks/is-mobile-device.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import {useIsSSR} from '@react-aria/ssr';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
|
||||
const MOBILE_SCREEN_WIDTH = 768;
|
||||
|
||||
export function useIsMobileDevice(): boolean {
|
||||
const isSSR = useIsSSR();
|
||||
if (isSSR || typeof window === 'undefined') {
|
||||
return getBootstrapData().is_mobile_device;
|
||||
}
|
||||
|
||||
return window.screen.width <= MOBILE_SCREEN_WIDTH;
|
||||
}
|
||||
5
common/resources/client/utils/hooks/is-mobile-media-query.ts
Executable file
5
common/resources/client/utils/hooks/is-mobile-media-query.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import { useMediaQuery, UseMediaQueryOptions } from "./use-media-query";
|
||||
|
||||
export function useIsMobileMediaQuery(options?: UseMediaQueryOptions) {
|
||||
return useMediaQuery("(max-width: 768px)", options);
|
||||
}
|
||||
5
common/resources/client/utils/hooks/is-tablet-media-query.ts
Executable file
5
common/resources/client/utils/hooks/is-tablet-media-query.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import {useMediaQuery, UseMediaQueryOptions} from './use-media-query';
|
||||
|
||||
export function useIsTabletMediaQuery(options?: UseMediaQueryOptions) {
|
||||
return useMediaQuery('(max-width: 1024px)', options);
|
||||
}
|
||||
5
common/resources/client/utils/hooks/is-touch-device.ts
Executable file
5
common/resources/client/utils/hooks/is-touch-device.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import {useMediaQuery} from './use-media-query';
|
||||
|
||||
export function useIsTouchDevice() {
|
||||
return useMediaQuery('((pointer: coarse))');
|
||||
}
|
||||
75
common/resources/client/utils/hooks/local-storage.ts
Executable file
75
common/resources/client/utils/hooks/local-storage.ts
Executable file
@@ -0,0 +1,75 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
interface StoreEvent {
|
||||
detail: {
|
||||
key: string;
|
||||
newValue: any;
|
||||
};
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T | null = null) {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
return getFromLocalStorage<T>(key, initialValue);
|
||||
});
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
setInLocalStorage(key, valueToStore);
|
||||
};
|
||||
|
||||
// update state value using custom storage event. This will re-render
|
||||
// component even if local storage value was set from different hook instance
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StoreEvent) => {
|
||||
if (event.detail?.key === key) {
|
||||
setStoredValue(event.detail.newValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorageChange as any);
|
||||
return () =>
|
||||
window.removeEventListener('storage', handleStorageChange as any);
|
||||
}, [key]);
|
||||
|
||||
return [storedValue, setValue] as const;
|
||||
}
|
||||
|
||||
export function getFromLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T | null = null,
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item != null ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function setInLocalStorage<T>(key: string, value: T) {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('storage', {
|
||||
detail: {key, newValue: value},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFromLocalStorage(key: string) {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch (error) {
|
||||
//
|
||||
}
|
||||
}
|
||||
22
common/resources/client/utils/hooks/sticky-sentinel.ts
Executable file
22
common/resources/client/utils/hooks/sticky-sentinel.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import {useCallback, useRef, useState} from 'react';
|
||||
|
||||
export function useStickySentinel() {
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
|
||||
const observerRef = useRef<IntersectionObserver>();
|
||||
|
||||
const sentinelRef = useCallback((sentinel: HTMLDivElement | null) => {
|
||||
if (sentinel) {
|
||||
const observer = new IntersectionObserver(
|
||||
([e]) => setIsSticky(e.intersectionRatio < 1),
|
||||
{threshold: [1]}
|
||||
);
|
||||
observerRef.current = observer;
|
||||
observer.observe(sentinel);
|
||||
} else if (observerRef.current) {
|
||||
observerRef.current?.disconnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {isSticky, sentinelRef};
|
||||
}
|
||||
14
common/resources/client/utils/hooks/use-block-body-overflow.ts
Executable file
14
common/resources/client/utils/hooks/use-block-body-overflow.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import {useEffect} from 'react';
|
||||
|
||||
export function useBlockBodyOverflow(disable: boolean = false) {
|
||||
useEffect(() => {
|
||||
if (disable) {
|
||||
document.documentElement.classList.remove('no-page-overflow');
|
||||
} else {
|
||||
document.documentElement.classList.add('no-page-overflow');
|
||||
}
|
||||
return () => {
|
||||
document.documentElement.classList.remove('no-page-overflow');
|
||||
};
|
||||
}, [disable]);
|
||||
}
|
||||
18
common/resources/client/utils/hooks/use-callback-ref.ts
Executable file
18
common/resources/client/utils/hooks/use-callback-ref.ts
Executable file
@@ -0,0 +1,18 @@
|
||||
import {useEffect, useMemo, useRef} from 'react';
|
||||
|
||||
/**
|
||||
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
|
||||
* prop or avoid re-executing effects when passed as a dependency
|
||||
*/
|
||||
export function useCallbackRef<T extends (...args: any[]) => any>(
|
||||
callback: T | undefined
|
||||
): T {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
});
|
||||
|
||||
// https://github.com/facebook/react/issues/19240
|
||||
return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
|
||||
}
|
||||
104
common/resources/client/utils/hooks/use-cookie.ts
Executable file
104
common/resources/client/utils/hooks/use-cookie.ts
Executable file
@@ -0,0 +1,104 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {isSsr} from '@common/utils/dom/is-ssr';
|
||||
|
||||
interface Options {
|
||||
days?: number;
|
||||
path?: string;
|
||||
domain?: string;
|
||||
SameSite?: 'None' | 'Lax' | 'Strict';
|
||||
Secure?: boolean;
|
||||
HttpOnly?: boolean;
|
||||
}
|
||||
|
||||
// used to notify different instances of useCookie hook about cookie changes
|
||||
const listeners = new Set<{name: string; callback: any}>();
|
||||
const listenForCookieChange = (
|
||||
name: string,
|
||||
callback: (value: string) => void,
|
||||
) => {
|
||||
if (isSsr()) return () => {};
|
||||
const listener = {name, callback};
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export function stringifyOptions(options: Options) {
|
||||
return Object.keys(options).reduce((acc, _key) => {
|
||||
const key = _key as keyof Options;
|
||||
if (key === 'days') {
|
||||
return acc;
|
||||
} else {
|
||||
if (options[key] === false) {
|
||||
return acc;
|
||||
} else if (options[key] === true) {
|
||||
return `${acc}; ${key}`;
|
||||
} else {
|
||||
return `${acc}; ${key}=${options[key]}`;
|
||||
}
|
||||
}
|
||||
}, '');
|
||||
}
|
||||
|
||||
export const setCookie = (name: string, value: string, options?: Options) => {
|
||||
if (isSsr()) return;
|
||||
|
||||
const optionsWithDefaults = {
|
||||
days: 7,
|
||||
path: '/',
|
||||
...options,
|
||||
};
|
||||
|
||||
const expires = new Date(
|
||||
Date.now() + optionsWithDefaults.days * 864e5,
|
||||
).toUTCString();
|
||||
|
||||
document.cookie =
|
||||
name +
|
||||
'=' +
|
||||
encodeURIComponent(value) +
|
||||
'; expires=' +
|
||||
expires +
|
||||
stringifyOptions(optionsWithDefaults);
|
||||
|
||||
listeners.forEach(listener => {
|
||||
if (listener.name === name) {
|
||||
listener.callback(value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function getCookie(name: string, initialValue = '') {
|
||||
return (
|
||||
(!isSsr() &&
|
||||
document.cookie &&
|
||||
document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, '')) ||
|
||||
initialValue
|
||||
);
|
||||
}
|
||||
|
||||
export function useCookie(key: string, initialValue?: string) {
|
||||
const [item, setItem] = useState(() => {
|
||||
return getCookie(key, initialValue);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return listenForCookieChange(key, value => {
|
||||
setItem(value);
|
||||
});
|
||||
}, [key]);
|
||||
|
||||
const updateItem = useCallback(
|
||||
(value: string, options?: Options) => {
|
||||
setItem(value);
|
||||
setCookie(key, value, options);
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [item, updateItem] as const;
|
||||
}
|
||||
14
common/resources/client/utils/hooks/use-linkified-string.ts
Executable file
14
common/resources/client/utils/hooks/use-linkified-string.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import {useMemo} from 'react';
|
||||
import linkifyStr from 'linkify-string';
|
||||
|
||||
export function useLinkifiedString(text: string | null | undefined) {
|
||||
return useMemo(() => {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
return linkifyStr(text, {
|
||||
nl2br: true,
|
||||
attributes: {rel: 'nofollow'},
|
||||
});
|
||||
}, [text]);
|
||||
}
|
||||
42
common/resources/client/utils/hooks/use-media-query.ts
Executable file
42
common/resources/client/utils/hooks/use-media-query.ts
Executable file
@@ -0,0 +1,42 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export interface UseMediaQueryOptions {
|
||||
noSSR?: boolean;
|
||||
}
|
||||
|
||||
export function useMediaQuery(
|
||||
query: string,
|
||||
{noSSR}: UseMediaQueryOptions = {noSSR: true}
|
||||
) {
|
||||
const supportsMatchMedia =
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function';
|
||||
const [matches, setMatches] = useState(
|
||||
noSSR
|
||||
? () => (supportsMatchMedia ? window.matchMedia(query).matches : false)
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsMatchMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mq = window.matchMedia(query);
|
||||
const onChange = () => {
|
||||
setMatches(mq.matches);
|
||||
};
|
||||
|
||||
mq.addEventListener('change', onChange);
|
||||
if (!noSSR) {
|
||||
onChange();
|
||||
}
|
||||
|
||||
return () => {
|
||||
mq.removeEventListener('change', onChange);
|
||||
};
|
||||
}, [supportsMatchMedia, query, noSSR]);
|
||||
|
||||
// If in SSR, the media query should never match. Once the page hydrates,
|
||||
// this will update and the real value will be returned.
|
||||
return typeof window === 'undefined' ? null : matches;
|
||||
}
|
||||
27
common/resources/client/utils/hooks/use-navigate.ts
Executable file
27
common/resources/client/utils/hooks/use-navigate.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
createPath,
|
||||
NavigateFunction,
|
||||
resolvePath,
|
||||
useLocation,
|
||||
useNavigate as useRouterNavigate
|
||||
} from 'react-router-dom';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export function useNavigate() {
|
||||
const routerNavigate = useRouterNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return useCallback(
|
||||
(to, options) => {
|
||||
// prevent duplicates in history when navigating to the same url
|
||||
const replace =
|
||||
createPath(location) === createPath(resolvePath(to, location.pathname));
|
||||
|
||||
routerNavigate(to, {
|
||||
...options,
|
||||
replace: options?.replace !== false && replace,
|
||||
});
|
||||
},
|
||||
[routerNavigate, location]
|
||||
) as NavigateFunction;
|
||||
}
|
||||
11
common/resources/client/utils/hooks/use-previous.ts
Executable file
11
common/resources/client/utils/hooks/use-previous.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import {useEffect, useRef} from 'react';
|
||||
|
||||
export function usePrevious<T>(value: T) {
|
||||
const ref = useRef<T>();
|
||||
// Store current value in ref
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]); // Only re-run if value changes
|
||||
// Return previous value (happens before update in useEffect above)
|
||||
return ref.current;
|
||||
}
|
||||
62
common/resources/client/utils/hooks/use-spin-delay.ts
Executable file
62
common/resources/client/utils/hooks/use-spin-delay.ts
Executable file
@@ -0,0 +1,62 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface SpinDelayOptions {
|
||||
delay?: number;
|
||||
minDuration?: number;
|
||||
}
|
||||
|
||||
type State = 'IDLE' | 'DELAY' | 'DISPLAY' | 'EXPIRE';
|
||||
|
||||
export const defaultOptions = {
|
||||
delay: 500,
|
||||
minDuration: 200,
|
||||
};
|
||||
|
||||
export function useSpinDelay(
|
||||
loading: boolean,
|
||||
options?: SpinDelayOptions,
|
||||
): boolean {
|
||||
options = Object.assign({}, defaultOptions, options);
|
||||
|
||||
const [state, setState] = useState<State>('IDLE');
|
||||
const timeout = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading && state === 'IDLE') {
|
||||
clearTimeout(timeout.current);
|
||||
|
||||
timeout.current = setTimeout(
|
||||
() => {
|
||||
if (!loading) {
|
||||
return setState('IDLE');
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(
|
||||
() => {
|
||||
setState('EXPIRE');
|
||||
},
|
||||
options?.minDuration,
|
||||
);
|
||||
|
||||
setState('DISPLAY');
|
||||
},
|
||||
options?.delay,
|
||||
);
|
||||
|
||||
setState('DELAY');
|
||||
}
|
||||
|
||||
if (!loading && state !== 'DISPLAY') {
|
||||
clearTimeout(timeout.current);
|
||||
setState('IDLE');
|
||||
}
|
||||
}, [loading, state, options.delay, options.minDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(timeout.current);
|
||||
}, []);
|
||||
|
||||
return state === 'DISPLAY' || state === 'EXPIRE';
|
||||
}
|
||||
|
||||
export default useSpinDelay;
|
||||
14
common/resources/client/utils/hooks/use-stable-scrollbar.ts
Executable file
14
common/resources/client/utils/hooks/use-stable-scrollbar.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import {useEffect} from 'react';
|
||||
|
||||
export function useStableScrollbar(disable: boolean = false) {
|
||||
useEffect(() => {
|
||||
if (disable) {
|
||||
document.documentElement.classList.remove('stable-scrollbar');
|
||||
} else {
|
||||
document.documentElement.classList.add('stable-scrollbar');
|
||||
}
|
||||
return () => {
|
||||
document.documentElement.classList.remove('stable-scrollbar');
|
||||
};
|
||||
}, [disable]);
|
||||
}
|
||||
Reference in New Issue
Block a user