+ "content": "import { MediaMatcher } from '@angular/cdk/layout';\nimport { isPlatformBrowser, DOCUMENT } from '@angular/common';\nimport { DestroyRef, Injectable, PLATFORM_ID, computed, effect, inject, signal } from '@angular/core';\n\nexport enum EDarkModes {\n LIGHT = 'light',\n DARK = 'dark',\n SYSTEM = 'system',\n}\nexport type DarkModeOptions = EDarkModes.LIGHT | EDarkModes.DARK | EDarkModes.SYSTEM;\n\n@Injectable({\n providedIn: 'root',\n})\nexport class ZardDarkMode {\n private readonly document = inject(DOCUMENT);\n private readonly destroyRef = inject(DestroyRef);\n\n private static readonly STORAGE_KEY = 'theme';\n private handleThemeChange = (event: MediaQueryListEvent) => this.updateThemeMode(event.matches, EDarkModes.SYSTEM);\n private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));\n private readonly themeSignal = signal<DarkModeOptions>(EDarkModes.SYSTEM);\n private _query?: MediaQueryList;\n private initialized = false;\n\n constructor() {\n if (this.isBrowser) {\n this._query = inject(MediaMatcher).matchMedia('(prefers-color-scheme: dark)');\n this.destroyRef.onDestroy(() => this.handleSystemChanges(false));\n\n effect(() => {\n const theme = this.themeSignal();\n const isDarkMode = this.isDarkModeActive(theme);\n this.updateThemeMode(isDarkMode, theme);\n });\n }\n }\n\n readonly themeMode = computed(() => {\n const currentTheme = this.themeSignal();\n if (currentTheme === EDarkModes.SYSTEM) {\n return this.isDarkModeActive(currentTheme) ? EDarkModes.DARK : EDarkModes.LIGHT;\n }\n return currentTheme;\n });\n\n init() {\n if (!this.initialized && this.isBrowser) {\n this.initializeTheme();\n }\n }\n\n toggleTheme(targetMode?: DarkModeOptions): void {\n if (!this.isBrowser) {\n return;\n }\n\n if (targetMode) {\n this.applyTheme(targetMode);\n } else {\n const next = this.themeMode() === EDarkModes.DARK ? EDarkModes.LIGHT : EDarkModes.DARK;\n this.applyTheme(next);\n }\n }\n\n /**\n * Returns a ReadonlySignal<\"light\" | \"dark\" | \"system\"> that cannot be mutated externally.\n * Call currentTheme() to access the value or use it directly in templates where signals are supported.\n * @example service.currentTheme() // returns \"light\", \"dark\", or \"system\"\n */\n get currentTheme() {\n return this.themeSignal.asReadonly();\n }\n\n private get query(): MediaQueryList {\n if (!this.isBrowser || !this._query) {\n throw new Error('Cannot access media query on server');\n }\n return this._query;\n }\n\n private initializeTheme(): void {\n const storedTheme = this.getStoredTheme();\n if (storedTheme) {\n this.themeSignal.set(storedTheme);\n }\n\n if (!storedTheme || storedTheme === EDarkModes.SYSTEM) {\n this.handleSystemChanges();\n }\n this.initialized = true;\n }\n\n private applyTheme(theme: DarkModeOptions): void {\n if (!this.isBrowser) {\n return;\n }\n\n try {\n localStorage.setItem(ZardDarkMode.STORAGE_KEY, theme);\n } catch (error) {\n console.warn('Failed to save theme to localStorage:', error);\n }\n this.themeSignal.set(theme);\n\n if (theme === EDarkModes.SYSTEM) {\n this.handleSystemChanges();\n } else {\n this.handleSystemChanges(false);\n }\n }\n\n private getStoredTheme(): DarkModeOptions | undefined {\n if (!this.isBrowser) {\n return undefined;\n }\n\n try {\n const value = localStorage.getItem(ZardDarkMode.STORAGE_KEY);\n if (value === EDarkModes.LIGHT || value === EDarkModes.DARK || value === EDarkModes.SYSTEM) {\n return value;\n }\n } catch (error) {\n console.warn('Failed to read theme from localStorage:', error);\n }\n return undefined;\n }\n\n private updateThemeMode(isDarkMode: boolean, themeMode: EDarkModes): void {\n const html = this.document.documentElement;\n html.classList.toggle('dark', isDarkMode);\n html.setAttribute('data-theme', themeMode);\n }\n\n private isDarkModeActive(currentTheme: DarkModeOptions): boolean {\n if (!this.isBrowser) {\n return false;\n }\n\n return currentTheme === EDarkModes.DARK || (currentTheme === EDarkModes.SYSTEM && this.query.matches);\n }\n\n private handleSystemChanges(addListener = true): void {\n try {\n if (addListener) {\n this.query.addEventListener('change', this.handleThemeChange);\n } else {\n this.query.removeEventListener('change', this.handleThemeChange);\n }\n } catch (error) {\n console.warn('Failed to manage media query event listener:', error);\n }\n }\n}\n"
0 commit comments