feat: Add theme switch

This commit is contained in:
deytenit committed 2026-02-15 15:36:31 +03:00
1 parent 891027cab0
commit 2d1bc9d119
38 files changed
+1724 -218

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
export * from './types/index.js';
export * from './functions/index.js';
export * from './window/localStorage.js';
export * from './react/index.js';
+2 -9
View File
@@ -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;
}