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
JavaScript
/**
* 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);
}