feat(use-theme): shared React theme toggle hook

This commit is contained in:
saravanakumardb1 2026-03-29 12:40:06 -07:00
parent c28dbc873e
commit 0f5dc91648
6 changed files with 270 additions and 0 deletions

View File

@ -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',

View 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/"
}
}

View 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');
});
});

View File

@ -0,0 +1,2 @@
export { useTheme } from './use-theme.js';
export type { Theme, UseThemeOptions, UseThemeReturn } from './use-theme.js';

View 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 };
}

View 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"]
}