feat(use-theme): shared React theme toggle hook
This commit is contained in:
parent
c28dbc873e
commit
0f5dc91648
@ -96,6 +96,7 @@ export default [
|
||||
MouseEvent: 'readonly',
|
||||
FocusEvent: 'readonly',
|
||||
CustomEvent: 'readonly',
|
||||
StorageEvent: 'readonly',
|
||||
MediaQueryList: 'readonly',
|
||||
MutationObserver: 'readonly',
|
||||
ResizeObserver: 'readonly',
|
||||
|
||||
35
packages/use-theme/package.json
Normal file
35
packages/use-theme/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@bytelyst/use-theme",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
|
||||
}
|
||||
}
|
||||
138
packages/use-theme/src/__tests__/use-theme.test.ts
Normal file
138
packages/use-theme/src/__tests__/use-theme.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useTheme } from '../index.js';
|
||||
|
||||
// localStorage mock
|
||||
const store: Record<string, string> = {};
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const key of Object.keys(store)) delete store[key];
|
||||
}),
|
||||
length: 0,
|
||||
key: vi.fn(() => null),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(store)) delete store[key];
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.className = '';
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
describe('useTheme', () => {
|
||||
it('defaults to dark when no stored value', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('reads initial value from localStorage', () => {
|
||||
store['theme'] = 'light';
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('reads initial value with custom storage key', () => {
|
||||
store['cm-theme'] = 'light';
|
||||
const { result } = renderHook(() => useTheme({ storageKey: 'cm-theme' }));
|
||||
expect(result.current.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('toggles between light and dark', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('dark');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
expect(result.current.theme).toBe('light');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
expect(result.current.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('persists to localStorage with configurable key', () => {
|
||||
const { result } = renderHook(() => useTheme({ storageKey: 'my-key' }));
|
||||
|
||||
act(() => {
|
||||
result.current.setTheme('light');
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('my-key', 'light');
|
||||
});
|
||||
|
||||
it('persists to localStorage with default key', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => {
|
||||
result.current.setTheme('light');
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'light');
|
||||
});
|
||||
|
||||
it('applies class to document.documentElement (default mode)', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
// Default dark
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setTheme('light');
|
||||
});
|
||||
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
it('supports data-theme attribute mode', () => {
|
||||
const { result } = renderHook(() => useTheme({ attribute: 'data-theme' }));
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
|
||||
act(() => {
|
||||
result.current.setTheme('light');
|
||||
});
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
|
||||
it('falls back to dark for invalid stored value', () => {
|
||||
store['theme'] = 'invalid';
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('setTheme updates state and localStorage', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => {
|
||||
result.current.setTheme('light');
|
||||
});
|
||||
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'light');
|
||||
});
|
||||
|
||||
it('toggleTheme persists to localStorage', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'light');
|
||||
});
|
||||
});
|
||||
2
packages/use-theme/src/index.ts
Normal file
2
packages/use-theme/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { useTheme } from './use-theme.js';
|
||||
export type { Theme, UseThemeOptions, UseThemeReturn } from './use-theme.js';
|
||||
83
packages/use-theme/src/use-theme.ts
Normal file
83
packages/use-theme/src/use-theme.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
export interface UseThemeOptions {
|
||||
/** localStorage key — default: 'theme' */
|
||||
storageKey?: string;
|
||||
/** Apply theme via class or data-theme attribute — default: 'class' */
|
||||
attribute?: 'class' | 'data-theme';
|
||||
}
|
||||
|
||||
export interface UseThemeReturn {
|
||||
theme: Theme;
|
||||
setTheme: (t: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
function readStoredTheme(key: string): Theme {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
}
|
||||
const stored = window.localStorage.getItem(key);
|
||||
return stored === 'light' || stored === 'dark' ? stored : 'dark';
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme, attribute: 'class' | 'data-theme') {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
if (attribute === 'data-theme') {
|
||||
root.setAttribute('data-theme', theme);
|
||||
} else {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme(options?: UseThemeOptions): UseThemeReturn {
|
||||
const storageKey = options?.storageKey ?? 'theme';
|
||||
const attribute = options?.attribute ?? 'class';
|
||||
|
||||
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme(storageKey));
|
||||
|
||||
// Apply theme to DOM whenever it changes
|
||||
useEffect(() => {
|
||||
applyTheme(theme, attribute);
|
||||
}, [theme, attribute]);
|
||||
|
||||
// Sync across tabs via storage event
|
||||
useEffect(() => {
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== storageKey) {
|
||||
return;
|
||||
}
|
||||
if (event.newValue === 'light' || event.newValue === 'dark') {
|
||||
setThemeState(event.newValue);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, [storageKey]);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(t: Theme) => {
|
||||
setThemeState(t);
|
||||
window.localStorage.setItem(storageKey, t);
|
||||
},
|
||||
[storageKey]
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState(prev => {
|
||||
const next: Theme = prev === 'dark' ? 'light' : 'dark';
|
||||
window.localStorage.setItem(storageKey, next);
|
||||
applyTheme(next, attribute);
|
||||
return next;
|
||||
});
|
||||
}, [storageKey, attribute]);
|
||||
|
||||
return { theme, setTheme, toggleTheme };
|
||||
}
|
||||
11
packages/use-theme/tsconfig.json
Normal file
11
packages/use-theme/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user