UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

364 lines (363 loc) 11.9 kB
/** * Theme management utilities for ClarityKit * Provides robust theme switching and detection capabilities with SSR support */ import { isBrowser, isServer, safelyAccessWindow, safelyAccessStorage, safelyAccessDOM } from './environment'; const THEME_STORAGE_KEY = 'ck-theme-preference'; const THEME_CLASS_PREFIX = 'ck-theme-'; /** * Gets the user's preferred color scheme from the system * @returns 'light' or 'dark' based on system preference, defaults to 'light' for SSR */ export function getSystemTheme() { if (isServer) return 'light'; // Default for SSR to prevent hydration mismatches return safelyAccessWindow(() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', 'light' // Fallback to light theme ) || 'light'; } /** * Gets the current theme preference from localStorage * @returns The stored theme preference or 'auto' if none is set */ export function getStoredTheme() { if (isServer) return 'auto'; // Default for SSR return safelyAccessStorage(() => localStorage.getItem(THEME_STORAGE_KEY) || 'auto', 'auto' // Fallback to auto theme ) || 'auto'; } /** * Stores the theme preference in localStorage * @param theme - The theme to store */ export function setStoredTheme(theme) { if (isServer) return; // Skip storage on server safelyAccessStorage(() => { localStorage.setItem(THEME_STORAGE_KEY, theme); }); } /** * Resolves the effective theme based on user preference and system settings * @param preference - The user's theme preference * @returns The actual theme to apply ('light' or 'dark') */ export function resolveTheme(preference) { if (preference === 'auto') { return getSystemTheme(); } return preference; } /** * Applies the theme to the document by adding/removing CSS classes * @param theme - The theme to apply ('light' or 'dark') */ export function applyTheme(theme) { if (isServer) return; // Skip DOM manipulation on server safelyAccessDOM(() => { const root = document.documentElement; // Remove all theme classes root.classList.remove(`${THEME_CLASS_PREFIX}light`, `${THEME_CLASS_PREFIX}dark`); // Add the new theme class root.classList.add(`${THEME_CLASS_PREFIX}${theme}`); // Set data attribute for CSS targeting root.setAttribute('data-theme', theme); }); } /** * Gets the currently applied theme from the document * @returns The currently applied theme or environment-appropriate fallback */ export function getCurrentAppliedTheme() { if (isServer) return getSSRDefaultTheme(); // Default for SSR to prevent hydration mismatches return safelyAccessDOM(() => { const root = document.documentElement; if (root.classList.contains(`${THEME_CLASS_PREFIX}light`)) { return 'light'; } if (root.classList.contains(`${THEME_CLASS_PREFIX}dark`)) { return 'dark'; } // If no theme class is applied, use environment default return getEnvironmentDefaultTheme(); }, getEnvironmentDefaultTheme()) || getEnvironmentDefaultTheme(); } /** * Creates a media query listener for system theme changes * @param callback - Function to call when system theme changes * @returns Function to remove the listener */ export function watchSystemTheme(callback) { if (isServer) return () => { }; // No-op on server return safelyAccessWindow(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e) => { callback(e.matches ? 'dark' : 'light'); }; // Use addEventListener if available (modern browsers), otherwise use deprecated addListener if (mediaQuery.addEventListener) { mediaQuery.addEventListener('change', handler); return () => mediaQuery.removeEventListener('change', handler); } else { // Fallback for older browsers mediaQuery.addListener(handler); return () => mediaQuery.removeListener(handler); } }, () => { }) || (() => { }); // Return no-op function as fallback } /** * Theme manager class that handles all theme-related operations with SSR support */ export class ThemeManager { constructor() { Object.defineProperty(this, "currentPreference", { enumerable: true, configurable: true, writable: true, value: 'auto' }); Object.defineProperty(this, "systemThemeWatcher", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "listeners", { enumerable: true, configurable: true, writable: true, value: [] }); Object.defineProperty(this, "isInitialized", { enumerable: true, configurable: true, writable: true, value: false }); // Initialize immediately in browser environment (including tests) // Defer only in actual server environment if (isBrowser) { this.initialize(); } else { // Set safe defaults for server rendering this.currentPreference = 'auto'; } } /** * Initializes the theme manager (client-side only) */ initialize() { if (isServer || this.isInitialized) return; // Load stored preference this.currentPreference = getStoredTheme(); // Apply initial theme this.updateTheme(); // Watch for system theme changes if preference is 'auto' this.setupSystemWatcher(); this.isInitialized = true; } /** * Ensures the theme manager is initialized (for lazy initialization on client) */ ensureInitialized() { if (isBrowser && !this.isInitialized) { this.initialize(); } } /** * Sets up the system theme watcher (client-side only) */ setupSystemWatcher() { if (isServer) return; this.cleanupSystemWatcher(); if (this.currentPreference === 'auto') { this.systemThemeWatcher = watchSystemTheme((systemTheme) => { if (this.currentPreference === 'auto') { this.applyAndNotify(systemTheme); } }); } } /** * Cleans up the system theme watcher */ cleanupSystemWatcher() { if (this.systemThemeWatcher) { this.systemThemeWatcher(); this.systemThemeWatcher = undefined; } } /** * Updates the applied theme based on current preference */ updateTheme() { const effectiveTheme = resolveTheme(this.currentPreference); this.applyAndNotify(effectiveTheme); } /** * Applies theme and notifies listeners */ applyAndNotify(theme) { applyTheme(theme); this.listeners.forEach(listener => listener(theme)); } /** * Sets the theme preference * @param theme - The theme preference to set */ setTheme(theme) { this.ensureInitialized(); this.currentPreference = theme; setStoredTheme(theme); this.setupSystemWatcher(); this.updateTheme(); } /** * Gets the current theme preference * @returns The current theme preference */ getThemePreference() { this.ensureInitialized(); return this.currentPreference; } /** * Gets the currently applied theme * @returns The currently applied theme */ getCurrentTheme() { this.ensureInitialized(); return getCurrentAppliedTheme(); } /** * Toggles between light and dark themes * If current preference is 'auto', sets to the opposite of current system theme */ toggleTheme() { this.ensureInitialized(); const currentEffective = resolveTheme(this.currentPreference); const newTheme = currentEffective === 'light' ? 'dark' : 'light'; this.setTheme(newTheme); } /** * Adds a listener for theme changes * @param listener - Function to call when theme changes * @returns Function to remove the listener */ onThemeChange(listener) { this.ensureInitialized(); this.listeners.push(listener); // Call immediately with current theme (only on client) if (isBrowser) { listener(this.getCurrentTheme()); } return () => { const index = this.listeners.indexOf(listener); if (index > -1) { this.listeners.splice(index, 1); } }; } /** * Cleans up all listeners and watchers */ destroy() { this.cleanupSystemWatcher(); this.listeners = []; } } /** * Gets the server-safe default theme for SSR rendering * This ensures consistent initial rendering between server and client * @returns Default theme for server-side rendering */ export function getSSRDefaultTheme() { return 'light'; // Always use light as default for SSR to prevent hydration mismatches } /** * Gets the default theme for the current environment * Uses light for SSR, but can use system preference for browser/tests * @returns Default theme for current environment */ export function getEnvironmentDefaultTheme() { if (isServer) { return getSSRDefaultTheme(); } // In browser environment (including tests), we can use system preference // but fall back to light if system detection fails return safelyAccessWindow(() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', 'light') || 'light'; } /** * Gets the effective theme for SSR rendering based on stored preference * This helps prevent hydration mismatches by using stored preference if available * @returns Theme to use for server-side rendering */ export function getSSRTheme() { if (isServer) { return getSSRDefaultTheme(); } // On client, resolve the actual theme but use environment default for 'auto' const preference = getStoredTheme(); if (preference === 'auto') { return getEnvironmentDefaultTheme(); } return preference; } /** * Initializes theme on client-side after hydration * This should be called in onMount to ensure proper theme application after SSR */ export function initializeClientTheme() { if (isServer) return; const themeManager = getThemeManager(); // Force re-initialization to ensure proper client-side setup themeManager['ensureInitialized'](); } /** * Global theme manager instance */ let globalThemeManager = null; /** * Gets or creates the global theme manager instance * @returns The global theme manager */ export function getThemeManager() { if (!globalThemeManager) { globalThemeManager = new ThemeManager(); } return globalThemeManager; } /** * Utility function to set theme preference using the global manager * @param theme - The theme to set */ export function setTheme(theme) { getThemeManager().setTheme(theme); } /** * Utility function to toggle theme using the global manager */ export function toggleTheme() { getThemeManager().toggleTheme(); } /** * Utility function to get current theme preference * @returns The current theme preference */ export function getThemePreference() { return getThemeManager().getThemePreference(); } /** * Utility function to add theme change listener * @param listener - Function to call when theme changes * @returns Function to remove the listener */ export function onThemeChange(listener) { return getThemeManager().onThemeChange(listener); }