diff --git a/eslint.config.js b/eslint.config.js index c2bff88e..fb27b412 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -96,6 +96,7 @@ export default [ MouseEvent: 'readonly', FocusEvent: 'readonly', CustomEvent: 'readonly', + StorageEvent: 'readonly', MediaQueryList: 'readonly', MutationObserver: 'readonly', ResizeObserver: 'readonly', diff --git a/packages/use-theme/package.json b/packages/use-theme/package.json new file mode 100644 index 00000000..9194c62a --- /dev/null +++ b/packages/use-theme/package.json @@ -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/" + } +} diff --git a/packages/use-theme/src/__tests__/use-theme.test.ts b/packages/use-theme/src/__tests__/use-theme.test.ts new file mode 100644 index 00000000..aa6f2936 --- /dev/null +++ b/packages/use-theme/src/__tests__/use-theme.test.ts @@ -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 = {}; +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'); + }); +}); diff --git a/packages/use-theme/src/index.ts b/packages/use-theme/src/index.ts new file mode 100644 index 00000000..a603b2d2 --- /dev/null +++ b/packages/use-theme/src/index.ts @@ -0,0 +1,2 @@ +export { useTheme } from './use-theme.js'; +export type { Theme, UseThemeOptions, UseThemeReturn } from './use-theme.js'; diff --git a/packages/use-theme/src/use-theme.ts b/packages/use-theme/src/use-theme.ts new file mode 100644 index 00000000..c6ce2c7c --- /dev/null +++ b/packages/use-theme/src/use-theme.ts @@ -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(() => 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 }; +} diff --git a/packages/use-theme/tsconfig.json b/packages/use-theme/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/use-theme/tsconfig.json @@ -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"] +}