UNPKG

react-theme-system

Version:

A comprehensive React theme management system that enforces consistency, supports dark/light mode, and eliminates hardcoded styles

176 lines (175 loc) 8.61 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.useTheme = exports.ThemeProvider = exports.VALID_THEMES = void 0; const jsx_runtime_1 = require("react/jsx-runtime"); const react_1 = require("react"); const theme_schema_1 = require("./utils/theme-schema"); // Theme validation constants exports.VALID_THEMES = ['light', 'dark']; // Theme storage key const THEME_STORAGE_KEY = 'react-theme-system-theme'; const ThemeContext = (0, react_1.createContext)(undefined); const ThemeProvider = ({ themes, children, defaultTheme = 'light', onChange, enablePersistence = true, enableSystemTheme = false, validateTheme = false }) => { // Use undefined initially to prevent hydration mismatch const [currentTheme, setCurrentTheme] = (0, react_1.useState)(undefined); const [customTheme, setCustomTheme] = (0, react_1.useState)(null); const [isHydrated, setIsHydrated] = (0, react_1.useState)(false); const [systemTheme, setSystemTheme] = (0, react_1.useState)(null); // Theme validation function const isValidTheme = (theme) => { return exports.VALID_THEMES.includes(theme); }; // System theme detection (0, react_1.useEffect)(() => { if (!enableSystemTheme || typeof window === 'undefined') return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const updateSystemTheme = () => { setSystemTheme(mediaQuery.matches ? 'dark' : 'light'); }; updateSystemTheme(); mediaQuery.addEventListener('change', updateSystemTheme); return () => mediaQuery.removeEventListener('change', updateSystemTheme); }, [enableSystemTheme]); // Theme validation (0, react_1.useEffect)(() => { if (validateTheme && themes) { const validation = theme_schema_1.themeValidator.validate(themes); if (!validation.isValid) { console.error('Theme validation failed:', validation.errors); } if (validation.warnings.length > 0) { console.warn('Theme validation warnings:', validation.warnings); } } }, [themes, validateTheme]); // Initialize theme on mount (SSR-safe) (0, react_1.useEffect)(() => { if (enablePersistence && typeof window !== 'undefined') { try { const storedTheme = localStorage.getItem(THEME_STORAGE_KEY); const theme = storedTheme && isValidTheme(storedTheme) ? storedTheme : (isValidTheme(defaultTheme) ? defaultTheme : 'light'); setCurrentTheme(theme); } catch (error) { console.warn('Failed to read theme from localStorage:', error); setCurrentTheme(isValidTheme(defaultTheme) ? defaultTheme : 'light'); } } else { setCurrentTheme(isValidTheme(defaultTheme) ? defaultTheme : 'light'); } setIsHydrated(true); }, [defaultTheme, enablePersistence]); // Persist theme changes const persistTheme = (0, react_1.useCallback)((theme) => { if (enablePersistence && typeof window !== 'undefined') { try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch (error) { console.warn('Failed to persist theme to localStorage:', error); } } }, [enablePersistence]); const theme = (0, react_1.useMemo)(() => { if (!themes) { // Return a minimal theme if no themes provided return { colors: { primary: '#000', secondary: '#666', accent: '#007bff', background: '#fff', surface: '#f8f9fa', text: { primary: '#000', secondary: '#666', disabled: '#999' }, border: '#dee2e6', error: '#dc3545', warning: '#ffc107', success: '#28a745', info: '#17a2b8' }, spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem', xl: '3rem', xxl: '4rem', scale: (m) => `${m * 0.25}rem` }, typography: { fontFamily: { primary: 'system-ui', secondary: 'Georgia', mono: 'monospace' }, fontSize: { xs: '0.75rem', sm: '0.875rem', base: '1rem', lg: '1.125rem', xl: '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem', '4xl': '2.25rem' }, fontWeight: { light: 300, normal: 400, medium: 500, semibold: 600, bold: 700 }, lineHeight: { tight: '1.25', normal: '1.5', relaxed: '1.75' } }, shadows: { sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)', xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1)', '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', none: 'none' }, borderRadius: { none: '0', sm: '0.125rem', md: '0.375rem', lg: '0.5rem', xl: '0.75rem', full: '9999px' }, breakpoints: { sm: '640px', md: '768px', lg: '1024px', xl: '1280px', '2xl': '1536px' }, transitions: { fast: '150ms', normal: '300ms', slow: '500ms', ease: { in: 'ease-in', out: 'ease-out', inOut: 'ease-in-out' } }, zIndex: { hide: -1, auto: 0, base: 0, docked: 10, dropdown: 1000, sticky: 1100, banner: 1200, overlay: 1300, modal: 1400, popover: 1500, skipLink: 1600, toast: 1700, tooltip: 1800 } }; } if (!currentTheme) return themes.light; // Fallback during SSR const baseTheme = currentTheme === 'dark' ? themes.dark : themes.light; return customTheme ? { ...baseTheme, ...customTheme } : baseTheme; }, [currentTheme, themes, customTheme]); // Get token value from theme const getToken = (0, react_1.useCallback)((path, fallback) => { if (!isHydrated || !themes) return fallback || ''; const keys = path.split('.'); let current = theme; for (const key of keys) { if (current && typeof current === 'object' && key in current) { current = current[key]; } else { console.warn(`Theme token not found: ${path}`); return fallback || ''; } } return current; }, [theme, isHydrated, themes]); // Get CSS variable string const getCSSVariable = (0, react_1.useCallback)((path, fallback) => { const value = getToken(path, fallback); const cssVarName = `--${path.replace(/\./g, '-')}`; return `var(${cssVarName}, ${fallback || value})`; }, [getToken]); const isDarkMode = currentTheme === 'dark'; const setTheme = (0, react_1.useCallback)((newTheme) => { if (!isValidTheme(newTheme)) { console.warn(`Invalid theme: ${newTheme}. Valid themes are: ${exports.VALID_THEMES.join(', ')}`); return; } setCurrentTheme(newTheme); persistTheme(newTheme); onChange?.(newTheme); }, [onChange, persistTheme]); const toggleTheme = (0, react_1.useCallback)(() => { const newTheme = isDarkMode ? 'light' : 'dark'; setTheme(newTheme); }, [isDarkMode, setTheme]); const updateTheme = (0, react_1.useCallback)((path, value) => { setCustomTheme(prev => { const newTheme = { ...prev }; const keys = path.split('.'); let current = newTheme; for (let i = 0; i < keys.length - 1; i++) { if (!current[keys[i]]) { current[keys[i]] = {}; } current = current[keys[i]]; } current[keys[keys.length - 1]] = value; return newTheme; }); }, []); const contextValue = { theme, isDarkMode, currentTheme, isHydrated, systemTheme, setTheme, toggleTheme, updateTheme, resetCustomTheme: () => setCustomTheme(null), getToken, getCSSVariable }; // Don't render until hydrated to prevent SSR mismatch if (!isHydrated) { return ((0, jsx_runtime_1.jsx)(ThemeContext.Provider, { value: contextValue, children: (0, jsx_runtime_1.jsx)("div", { style: { visibility: 'hidden' }, children: children }) })); } return ((0, jsx_runtime_1.jsx)(ThemeContext.Provider, { value: contextValue, children: children })); }; exports.ThemeProvider = ThemeProvider; const useTheme = () => { const context = (0, react_1.useContext)(ThemeContext); if (!context) { throw new Error('useTheme must be used within ThemeProvider'); } return context; }; exports.useTheme = useTheme;