aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/components/organisms/forms/ackee-toggle/ackee-toggle.tsx8
-rw-r--r--src/components/organisms/forms/motion-toggle/motion-toggle.tsx10
-rw-r--r--src/services/local-storage/index.ts27
-rw-r--r--src/services/local-storage/local-storage.test.ts92
-rw-r--r--src/services/local-storage/local-storage.ts26
-rw-r--r--src/types/app.ts2
-rw-r--r--src/utils/hooks/use-local-storage.tsx33
-rw-r--r--src/utils/hooks/use-local-storage/index.ts1
-rw-r--r--src/utils/hooks/use-local-storage/use-local-storage.test.ts59
-rw-r--r--src/utils/hooks/use-local-storage/use-local-storage.ts38
-rw-r--r--src/utils/providers/prism-theme.tsx61
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>
);