diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx | 8 | ||||
| -rw-r--r-- | src/components/organisms/forms/motion-toggle/motion-toggle.tsx | 10 | ||||
| -rw-r--r-- | src/services/local-storage/index.ts | 27 | ||||
| -rw-r--r-- | src/services/local-storage/local-storage.test.ts | 92 | ||||
| -rw-r--r-- | src/services/local-storage/local-storage.ts | 26 | ||||
| -rw-r--r-- | src/types/app.ts | 2 | ||||
| -rw-r--r-- | src/utils/hooks/use-local-storage.tsx | 33 | ||||
| -rw-r--r-- | src/utils/hooks/use-local-storage/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-local-storage/use-local-storage.test.ts | 59 | ||||
| -rw-r--r-- | src/utils/hooks/use-local-storage/use-local-storage.ts | 38 | ||||
| -rw-r--r-- | src/utils/providers/prism-theme.tsx | 61 |
11 files changed, 267 insertions, 90 deletions
diff --git a/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx index 8ada948..a9c172b 100644 --- a/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx +++ b/src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx @@ -15,6 +15,9 @@ import { type TooltipProps, } from '../../../molecules'; +const validator = (value: unknown): value is AckeeOptions => + value === 'full' || value === 'partial'; + export type AckeeToggleProps = Omit< SwitchProps, 'isInline' | 'items' | 'name' | 'onSwitch' | 'value' @@ -46,9 +49,10 @@ export const AckeeToggle: FC<AckeeToggleProps> = ({ ...props }) => { const intl = useIntl(); - const { value, setValue } = useLocalStorage<AckeeOptions>( + const [value, setValue] = useLocalStorage( storageKey, - defaultValue + defaultValue, + validator ); const [isTooltipOpened, setIsTooltipOpened] = useState(false); diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx index c141bf0..2545c20 100644 --- a/src/components/organisms/forms/motion-toggle/motion-toggle.tsx +++ b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx @@ -10,6 +10,9 @@ import { export type MotionToggleValue = 'on' | 'off'; +const validator = (value: unknown): value is boolean => + typeof value === 'boolean'; + export type MotionToggleProps = Omit< SwitchProps, 'isInline' | 'items' | 'name' | 'onSwitch' | 'value' @@ -17,7 +20,7 @@ export type MotionToggleProps = Omit< /** * True if motion should be reduced by default. */ - defaultValue: 'on' | 'off'; + defaultValue: MotionToggleValue; /** * The local storage key to save preference. */ @@ -35,9 +38,10 @@ export const MotionToggle: FC<MotionToggleProps> = ({ ...props }) => { const intl = useIntl(); - const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>( + const [isReduced, setIsReduced] = useLocalStorage( storageKey, - defaultValue !== 'on' + defaultValue !== 'on', + validator ); useAttributes({ element: diff --git a/src/services/local-storage/index.ts b/src/services/local-storage/index.ts index 65235a7..72dd18f 100644 --- a/src/services/local-storage/index.ts +++ b/src/services/local-storage/index.ts @@ -1,26 +1 @@ -export const LocalStorage = { - get<T>(key: string): T | undefined { - try { - const serialItem = localStorage.getItem(key); - if (!serialItem) return undefined; - return JSON.parse(serialItem) as T; - } catch (e) { - console.log(e); - return undefined; - } - }, - set<T>(key: string, value: T) { - try { - const serialItem = JSON.stringify(value); - localStorage.setItem(key, serialItem); - } catch (e) { - console.log(e); - } - }, - remove(key: string) { - localStorage.removeItem(key); - }, - clear() { - localStorage.clear(); - }, -}; +export * from './local-storage'; diff --git a/src/services/local-storage/local-storage.test.ts b/src/services/local-storage/local-storage.test.ts new file mode 100644 index 0000000..df3c646 --- /dev/null +++ b/src/services/local-storage/local-storage.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { LocalStorage } from './local-storage'; + +describe('LocalStorage', () => { + it('should return an undefined value when the key is not set', () => { + localStorage.clear(); + + expect(LocalStorage.get('et')).toBeUndefined(); + }); + + it('can set a new key and return its value', () => { + localStorage.clear(); + + const key = 'laudantium'; + const value = 'laborum'; + + LocalStorage.set(key, value); + + expect(LocalStorage.get(key)).toBe(value); + }); + + it('can update an existing key', () => { + localStorage.clear(); + + const key = 'officiis'; + const value = 'saepe'; + + LocalStorage.set(key, value); + + const newValue = 'itaque'; + + LocalStorage.set(key, newValue); + + expect(LocalStorage.get(key)).toBe(newValue); + }); + + it('can remove a key from the storage', () => { + localStorage.clear(); + + const key1 = 'ab'; + const value1 = 'ipsum'; + const key2 = 'suscipit'; + const value2 = 'autem'; + + LocalStorage.set(key1, value1); + LocalStorage.set(key2, value2); + LocalStorage.remove(key1); + + expect(LocalStorage.get(key1)).toBeUndefined(); + expect(LocalStorage.get(key2)).toBe(value2); + }); + + it('can clear the storage', () => { + localStorage.clear(); + + const key1 = 'velit'; + const value1 = 'rerum'; + const key2 = 'enim'; + const value2 = 'consequatur'; + + LocalStorage.set(key1, value1); + LocalStorage.set(key2, value2); + LocalStorage.clear(); + + expect(LocalStorage.get(key1)).toBeUndefined(); + expect(LocalStorage.get(key2)).toBeUndefined(); + }); + + it('return undefined and log and error when the value is invalid', () => { + const spy = jest.spyOn(console, 'error'); + const key = 'dolor'; + const value = 'possimus'; + + // The value is not stringified + localStorage.setItem(key, value); + + expect(LocalStorage.get(key)).toBeUndefined(); + expect(spy).toHaveBeenCalled(); + }); + + it('does not set invalid value and log the error', () => { + const spy = jest.spyOn(console, 'error'); + const key = 'voluptatibus'; + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const value = BigInt(1234567890); + + LocalStorage.set(key, value); + + expect(LocalStorage.get(key)).toBeUndefined(); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/services/local-storage/local-storage.ts b/src/services/local-storage/local-storage.ts new file mode 100644 index 0000000..e9a50a5 --- /dev/null +++ b/src/services/local-storage/local-storage.ts @@ -0,0 +1,26 @@ +export const LocalStorage = { + get<T>(key: string): T | undefined { + try { + const serialItem = localStorage.getItem(key); + if (!serialItem) return undefined; + return JSON.parse(serialItem) as T; + } catch (e) { + console.error(e); + return undefined; + } + }, + set<T>(key: string, value: T) { + try { + const serialItem = JSON.stringify(value); + localStorage.setItem(key, serialItem); + } catch (e) { + console.error(e); + } + }, + remove(key: string) { + localStorage.removeItem(key); + }, + clear() { + localStorage.clear(); + }, +}; diff --git a/src/types/app.ts b/src/types/app.ts index 2e892b8..93ba1db 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -135,3 +135,5 @@ export type Position = 'bottom' | 'center' | 'left' | 'right' | 'top'; /** Spacing keys defined has CSS variables */ export type Spacing = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'; + +export type Validator<T> = (value: unknown) => value is T; diff --git a/src/utils/hooks/use-local-storage.tsx b/src/utils/hooks/use-local-storage.tsx deleted file mode 100644 index 0f9fbb6..0000000 --- a/src/utils/hooks/use-local-storage.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { LocalStorage } from '../../services/local-storage'; -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; - -export type UseLocalStorageReturn<T> = { - value: T; - setValue: Dispatch<SetStateAction<T>>; -}; - -/** - * Use the local storage. - * - * @param {string} key - The storage local key. - * @param {T} [fallbackValue] - A fallback value if local storage is empty. - * @returns {UseLocalStorageReturn<T>} An object with value and setValue. - */ -export const useLocalStorage = <T,>( - key: string, - fallbackValue: T -): UseLocalStorageReturn<T> => { - const getInitialValue = () => { - if (typeof window === 'undefined') return fallbackValue; - const storedValue = LocalStorage.get<T>(key); - return storedValue ?? fallbackValue; - }; - - const [value, setValue] = useState<T>(getInitialValue); - - useEffect(() => { - LocalStorage.set(key, value); - }, [key, value]); - - return { value, setValue }; -}; diff --git a/src/utils/hooks/use-local-storage/index.ts b/src/utils/hooks/use-local-storage/index.ts new file mode 100644 index 0000000..87c1953 --- /dev/null +++ b/src/utils/hooks/use-local-storage/index.ts @@ -0,0 +1 @@ +export * from './use-local-storage'; diff --git a/src/utils/hooks/use-local-storage/use-local-storage.test.ts b/src/utils/hooks/use-local-storage/use-local-storage.test.ts new file mode 100644 index 0000000..3b63495 --- /dev/null +++ b/src/utils/hooks/use-local-storage/use-local-storage.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import { LocalStorage } from '../../../services/local-storage'; +import { useLocalStorage } from './use-local-storage'; + +const validator = (value: unknown): value is string => + typeof value === 'string'; + +describe('useLocalStorage', () => { + const fallback = 'fuga'; + const key = 'qui'; + + it('should return the fallback value when storage is clear', () => { + LocalStorage.clear(); + + const { result } = renderHook(() => + useLocalStorage(key, fallback, validator) + ); + + expect(result.current[0]).toBe(fallback); + }); + + it('should return the stored value when storage is not clear', () => { + const storedValue = 'unde'; + + LocalStorage.set(key, storedValue); + + const { result } = renderHook(() => + useLocalStorage(key, fallback, validator) + ); + + expect(result.current[0]).toBe(storedValue); + }); + + it('should return the fallback value when the stored value is invalid', () => { + LocalStorage.clear(); + + const storedValue = 42; + + LocalStorage.set(key, storedValue); + + const { result } = renderHook(() => + useLocalStorage(key, fallback, validator) + ); + + expect(result.current[0]).toBe(fallback); + }); + + it('can update the stored value', () => { + const { result } = renderHook(() => + useLocalStorage(key, fallback, validator) + ); + const newValue = 'eveniet'; + + act(() => result.current[1](newValue)); + + expect(result.current[0]).toBe(newValue); + }); +}); diff --git a/src/utils/hooks/use-local-storage/use-local-storage.ts b/src/utils/hooks/use-local-storage/use-local-storage.ts new file mode 100644 index 0000000..47b98ff --- /dev/null +++ b/src/utils/hooks/use-local-storage/use-local-storage.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import { LocalStorage } from '../../../services/local-storage'; +import type { Validator } from '../../../types'; + +const getInitialValueOrFallback = <T>( + key: string, + fallbackValue: T, + validator: Validator<T> +) => { + if (typeof window === 'undefined') return fallbackValue; + const storedValue = LocalStorage.get(key); + + return validator(storedValue) ? storedValue : fallbackValue; +}; + +/** + * Use the local storage. + * + * @param {string} key - The storage local key. + * @param {T} fallbackValue - A fallback value if local storage is empty. + * @param {Validator<T>} validator - A function to validate the stored value. + * @returns A tuple with the value and a setter. + */ +export const useLocalStorage = <T>( + key: string, + fallbackValue: T, + validator: Validator<T> +) => { + const [value, setValue] = useState( + getInitialValueOrFallback(key, fallbackValue, validator) + ); + + useEffect(() => { + LocalStorage.set(key, value); + }, [key, value]); + + return [value, setValue] as const; +}; diff --git a/src/utils/providers/prism-theme.tsx b/src/utils/providers/prism-theme.tsx index c063bd5..603e8a7 100644 --- a/src/utils/providers/prism-theme.tsx +++ b/src/utils/providers/prism-theme.tsx @@ -1,11 +1,13 @@ +/* eslint-disable max-statements */ import { createContext, - FC, - ReactNode, + type FC, + type ReactNode, useCallback, useContext, useEffect, useState, + useMemo, } from 'react'; import { useAttributes, useLocalStorage, useQuerySelectorAll } from '../hooks'; @@ -42,12 +44,9 @@ export const usePrismTheme = () => useContext(PrismThemeContext); * @returns {boolean|undefined} True if `prefers-color-scheme` is set to `dark`. */ const prefersDarkScheme = (): boolean | undefined => { - if (typeof window === 'undefined') return; + if (typeof window === 'undefined') return undefined; - return ( - window.matchMedia && - window.matchMedia('(prefers-color-scheme: dark)').matches - ); + return window.matchMedia('(prefers-color-scheme: dark)').matches; }; /** @@ -56,25 +55,33 @@ const prefersDarkScheme = (): boolean | undefined => { * @param {string} theme - A string. * @returns {boolean} True if the given string match a Prism theme name. */ -const isValidTheme = (theme: string): boolean => { - return theme === 'dark' || theme === 'light' || theme === 'system'; -}; +const isValidTheme = (theme: string): boolean => + theme === 'dark' || theme === 'light' || theme === 'system'; + +const defaultThemes = ['dark', 'light', 'system'] satisfies PrismTheme[]; + +const validator = (value: unknown): value is PrismTheme => + typeof value === 'string' && (defaultThemes as string[]).includes(value); export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({ attribute = 'data-prismjs-color-scheme-current', storageKey = 'prismjs-color-scheme', - themes = ['dark', 'light', 'system'], + themes = defaultThemes, children, }) => { /** * Retrieve the theme to use depending on `prefers-color-scheme`. */ const getThemeFromSystem = useCallback(() => { - return prefersDarkScheme() ? 'dark' : 'light'; + if (prefersDarkScheme()) return 'dark'; + return 'light'; }, []); - const { value: prismTheme, setValue: setPrismTheme } = - useLocalStorage<PrismTheme>(storageKey, 'system'); + const [prismTheme, setPrismTheme] = useLocalStorage<PrismTheme>( + storageKey, + 'system', + validator + ); useEffect(() => { if (!isValidTheme(prismTheme)) setPrismTheme('system'); @@ -113,10 +120,10 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({ */ const listenAttributeChange = useCallback( (pre: HTMLPreElement) => { - var observer = new MutationObserver(function (mutations) { + const observer = new MutationObserver((mutations) => { mutations.forEach((record) => { - var mutatedPre = record.target as HTMLPreElement; - var newTheme = mutatedPre.getAttribute(attribute) as PrismTheme; + const mutatedPre = record.target as HTMLPreElement; + const newTheme = mutatedPre.getAttribute(attribute) as PrismTheme; setPrismTheme(newTheme); }); }); @@ -133,16 +140,18 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({ preTags.forEach(listenAttributeChange); }, [preTags, listenAttributeChange]); + const value = useMemo(() => { + return { + themes, + theme: prismTheme, + setTheme: setPrismTheme, + codeBlocks: preTags, + resolvedTheme, + }; + }, [preTags, prismTheme, resolvedTheme, setPrismTheme, themes]); + return ( - <PrismThemeContext.Provider - value={{ - themes, - theme: prismTheme, - setTheme: setPrismTheme, - codeBlocks: preTags, - resolvedTheme, - }} - > + <PrismThemeContext.Provider value={value}> {children} </PrismThemeContext.Provider> ); |
