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',
|
MouseEvent: 'readonly',
|
||||||
FocusEvent: 'readonly',
|
FocusEvent: 'readonly',
|
||||||
CustomEvent: 'readonly',
|
CustomEvent: 'readonly',
|
||||||
|
StorageEvent: 'readonly',
|
||||||
MediaQueryList: 'readonly',
|
MediaQueryList: 'readonly',
|
||||||
MutationObserver: 'readonly',
|
MutationObserver: 'readonly',
|
||||||
ResizeObserver: '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