feat: Add theme switch
This commit is contained in:
38 files changed
+1724
-218
No files matched your search
@@ -1,3 +1,4 @@
|
||||
export * from './types/index.js';
|
||||
export * from './functions/index.js';
|
||||
export * from './window/localStorage.js';
|
||||
export * from './react/index.js';
|
||||
@@ -1,11 +1,4 @@
|
||||
export { castRef } from './castRef/castRef';
|
||||
export { genericMemo } from './genericMemo/genericMemo';
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Common props for any React element.
|
||||
* Useful for forwarding props to dynamic components.
|
||||
*/
|
||||
export type ReactElementProps = React.HTMLAttributes<HTMLElement> &
|
||||
React.AllHTMLAttributes<HTMLElement>;
|
||||
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
|
||||
export type { ReactElementProps } from './types/ReactElementProps';
|
||||
@@ -0,0 +1,8 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Common props for any React element.
|
||||
* Useful for forwarding props to dynamic components.
|
||||
*/
|
||||
export type ReactElementProps = React.HTMLAttributes<HTMLElement> &
|
||||
React.AllHTMLAttributes<HTMLElement>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useLocalStorage';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
import { createLocalStorage } from '../../window/localStorage';
|
||||
|
||||
describe('lib/react/useLocalStorage', () => {
|
||||
const KEY = 'ermnvldmr/stl/lib/react/useLocalStorage/test';
|
||||
const DEFAULT_VALUE = 'default';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return default value initially', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
expect(result.current[0]).toBe(DEFAULT_VALUE);
|
||||
});
|
||||
|
||||
it('should update state when setter is called', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('new value');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('new value');
|
||||
expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify('new value'));
|
||||
});
|
||||
|
||||
it('should sync across tabs via storage event', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
|
||||
act(() => {
|
||||
// Simulate external localStorage update
|
||||
window.localStorage.setItem(KEY, JSON.stringify('external update'));
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: KEY,
|
||||
newValue: JSON.stringify('external update'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('external update');
|
||||
});
|
||||
|
||||
it('should support functional updates', () => {
|
||||
const manager = createLocalStorage(KEY, 0);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import type { StorageManager } from '../../window/localStorage';
|
||||
|
||||
/**
|
||||
* A React hook that synchronizes state with a StorageManager.
|
||||
*
|
||||
* It provides reactivity across the component lifecycle and synchronizes
|
||||
* values across multiple browser tabs using the 'storage' event.
|
||||
*
|
||||
* @template T - The type of the stored value.
|
||||
* @param manager - The StorageManager instance returned by createLocalStorage.
|
||||
* @returns A tuple containing the current value and a setter function.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [theme, setTheme] = useLocalStorage(themeStorage);
|
||||
* ```
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
manager: StorageManager<T>
|
||||
): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => {
|
||||
const handleStorageChange = (event: StorageEvent): void => {
|
||||
if (event.key === manager.key) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
},
|
||||
[manager.key]
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => manager.get(), [manager]);
|
||||
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
|
||||
// During SSR, we can't access localStorage.
|
||||
// However, createLocalStorage is designed to throw if accessed during SSR.
|
||||
// This hook is intended to be used in client-side components.
|
||||
return manager.get();
|
||||
});
|
||||
|
||||
const setValue = useCallback(
|
||||
(nextValue: T | ((prev: T) => T)) => {
|
||||
manager.set(nextValue);
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { createLocalStorage } from './localStorage';
|
||||
|
||||
describe('lib/window/localStorage', () => {
|
||||
const mockStorage: Record<string, string> = {};
|
||||
const KEY = 'ermnvldmr/stl/lib/window/test';
|
||||
const DEFAULT_VALUE = { foo: 'bar' };
|
||||
|
||||
const getItem = vi.fn((key: string) => mockStorage[key] || null);
|
||||
const setItem = vi.fn((key: string, value: string) => {
|
||||
mockStorage[key] = value;
|
||||
});
|
||||
const removeItem = vi.fn((key: string) => {
|
||||
delete mockStorage[key];
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getItem.mockClear();
|
||||
setItem.mockClear();
|
||||
removeItem.mockClear();
|
||||
|
||||
// Mock localStorage
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem,
|
||||
setItem,
|
||||
removeItem,
|
||||
},
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('StorageEvent', class {
|
||||
constructor(type: string, init: any) {
|
||||
Object.assign(this, { type, ...init });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
for (const key in mockStorage) delete mockStorage[key];
|
||||
});
|
||||
|
||||
it('should throw if window is undefined', () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
expect(() => createLocalStorage(KEY, DEFAULT_VALUE)).toThrow();
|
||||
});
|
||||
|
||||
it('should get default value if empty', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
expect(manager.get()).toEqual(DEFAULT_VALUE);
|
||||
});
|
||||
|
||||
it('should set and get values', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const newValue = { foo: 'updated' };
|
||||
manager.set(newValue);
|
||||
expect(manager.get()).toEqual(newValue);
|
||||
expect(setItem).toHaveBeenCalledWith(KEY, JSON.stringify(newValue));
|
||||
});
|
||||
|
||||
it('should support functional updates', () => {
|
||||
const manager = createLocalStorage(KEY, 0);
|
||||
manager.set((prev) => prev + 1);
|
||||
expect(manager.get()).toBe(1);
|
||||
manager.set((prev) => prev + 1);
|
||||
expect(manager.get()).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove items', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
manager.set({ foo: 'baz' });
|
||||
manager.remove();
|
||||
expect(manager.get()).toEqual(DEFAULT_VALUE);
|
||||
expect(removeItem).toHaveBeenCalledWith(KEY);
|
||||
});
|
||||
|
||||
it('should dispatch storage event on set', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
manager.set({ foo: 'event' });
|
||||
expect(window.dispatchEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to default value on parse error', () => {
|
||||
mockStorage[KEY] = 'invalid-json';
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
expect(manager.get()).toEqual(DEFAULT_VALUE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Manages type-safe interaction with window.localStorage.
|
||||
*
|
||||
* @template T - The type of the value to store (must be JSON serializable).
|
||||
*/
|
||||
export interface StorageManager<T> {
|
||||
/** Retrieves the current value or the default value if none exists. */
|
||||
get: () => T;
|
||||
/** Updates the stored value and persists it. */
|
||||
set: (value: T | ((prev: T) => T)) => void;
|
||||
/** Removes the item from storage. */
|
||||
remove: () => void;
|
||||
/** The key used in localStorage. */
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a type-safe manager for a localStorage key.
|
||||
*
|
||||
* This utility is browser-only and will throw if accessed during SSR.
|
||||
* Ensure you use a strict namespace for the key to avoid monorepo collisions.
|
||||
* Recommended format: `ermnvldmr/{package}/{path}/{module}`
|
||||
* Example: `ermnvldmr/ui/lib/theme`
|
||||
*
|
||||
* @param key - The unique key for storage.
|
||||
* @param defaultValue - The fallback value if storage is empty or corrupted.
|
||||
* @returns A StorageManager object.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const theme = createLocalStorage('ermnvldmr/ui/lib/theme', 'system');
|
||||
* console.log(theme.get());
|
||||
* ```
|
||||
*
|
||||
* @throws Error if window or localStorage is undefined.
|
||||
*/
|
||||
export function createLocalStorage<T>(key: string, defaultValue: T): StorageManager<T> {
|
||||
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
|
||||
throw new Error(
|
||||
`[createLocalStorage] Attempted to access storage for key "${key}" outside of a browser environment. ` +
|
||||
'This utility is strictly for client-side use.'
|
||||
);
|
||||
}
|
||||
|
||||
const manager: StorageManager<T> = {
|
||||
key,
|
||||
get: () => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (!item) return defaultValue;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return JSON.parse(item) as T;
|
||||
} catch (error) {
|
||||
console.error(`[createLocalStorage] Failed to parse key "${key}", falling back to default.`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
try {
|
||||
const nextValue = value instanceof Function ? value(manager.get()) : value;
|
||||
window.localStorage.setItem(key, JSON.stringify(nextValue));
|
||||
|
||||
// Dispatch a custom event to notify other listeners in the same tab
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key,
|
||||
newValue: JSON.stringify(nextValue),
|
||||
storageArea: window.localStorage,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[createLocalStorage] Failed to set key "${key}".`, error);
|
||||
}
|
||||
},
|
||||
remove: () => {
|
||||
window.localStorage.removeItem(key);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key,
|
||||
newValue: null,
|
||||
storageArea: window.localStorage,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return manager;
|
||||
}
|
||||
Reference in new issue
Block a user