189 lines
5.1 KiB
TypeScript
189 lines
5.1 KiB
TypeScript
/**
|
|
* Theme management with system preference detection
|
|
*
|
|
* Manages light/dark theme state with localStorage persistence
|
|
* and automatic system preference detection. Themes are applied
|
|
* via CSS class on the document element.
|
|
*
|
|
* Features:
|
|
* - Persists user preference to localStorage
|
|
* - Detects OS-level theme preference
|
|
* - Responds to OS theme changes when in "system" mode
|
|
* - Toggle between light/dark themes
|
|
* - Reset to follow system preference
|
|
*
|
|
* @example
|
|
* ```svelte
|
|
* <script lang="ts">
|
|
* import { themeManager } from '$features/ChangeAppTheme';
|
|
*
|
|
* // Initialize once on app mount
|
|
* onMount(() => themeManager.init());
|
|
* onDestroy(() => themeManager.destroy());
|
|
* </script>
|
|
*
|
|
* <button on:click={() => themeManager.toggle()}>
|
|
* {themeManager.isDark ? 'Switch to Light' : 'Switch to Dark'}
|
|
* </button>
|
|
* ```
|
|
*/
|
|
|
|
import { createPersistentStore } from '$shared/lib';
|
|
|
|
type Theme = 'light' | 'dark';
|
|
type ThemeSource = 'system' | 'user';
|
|
|
|
/**
|
|
* Theme manager singleton
|
|
*
|
|
* Call init() on app mount and destroy() on app unmount.
|
|
* Use isDark property to conditionally apply styles.
|
|
*/
|
|
class ThemeManager {
|
|
// Private reactive state
|
|
/** Current theme value ('light' or 'dark') */
|
|
#theme = $state<Theme>('light');
|
|
/** Whether theme is controlled by user or follows system */
|
|
#source = $state<ThemeSource>('system');
|
|
/** MediaQueryList for detecting system theme changes */
|
|
#mediaQuery: MediaQueryList | null = null;
|
|
/** Persistent storage for user's theme preference */
|
|
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
|
/** Bound handler for system theme change events */
|
|
#systemChangeHandler = this.#onSystemChange.bind(this);
|
|
|
|
constructor() {
|
|
// Derive initial values from stored preference or OS
|
|
const stored = this.#store.value;
|
|
if (stored === 'dark' || stored === 'light') {
|
|
this.#theme = stored;
|
|
this.#source = 'user';
|
|
} else {
|
|
this.#theme = this.#getSystemTheme();
|
|
this.#source = 'system';
|
|
}
|
|
}
|
|
|
|
/** Current theme value */
|
|
get value(): Theme {
|
|
return this.#theme;
|
|
}
|
|
|
|
/** Source of current theme ('system' or 'user') */
|
|
get source(): ThemeSource {
|
|
return this.#source;
|
|
}
|
|
|
|
/** Whether dark theme is active */
|
|
get isDark(): boolean {
|
|
return this.#theme === 'dark';
|
|
}
|
|
|
|
/** Whether theme is controlled by user (not following system) */
|
|
get isUserControlled(): boolean {
|
|
return this.#source === 'user';
|
|
}
|
|
|
|
/**
|
|
* Initialize theme manager
|
|
*
|
|
* Applies current theme to DOM and sets up system preference listener.
|
|
* Call once in root component onMount.
|
|
*/
|
|
init(): void {
|
|
this.#applyToDom(this.#theme);
|
|
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
this.#mediaQuery.addEventListener('change', this.#systemChangeHandler);
|
|
}
|
|
|
|
/**
|
|
* Clean up theme manager
|
|
*
|
|
* Removes system preference listener.
|
|
* Call in root component onDestroy.
|
|
*/
|
|
destroy(): void {
|
|
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
|
this.#mediaQuery = null;
|
|
}
|
|
|
|
/**
|
|
* Set theme explicitly
|
|
*
|
|
* Switches to user control and applies specified theme.
|
|
*
|
|
* @param theme - Theme to apply ('light' or 'dark')
|
|
*/
|
|
setTheme(theme: Theme): void {
|
|
this.#source = 'user';
|
|
this.#theme = theme;
|
|
this.#store.value = theme;
|
|
this.#applyToDom(theme);
|
|
}
|
|
|
|
/**
|
|
* Toggle between light and dark themes
|
|
*/
|
|
toggle(): void {
|
|
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
|
|
}
|
|
|
|
/**
|
|
* Reset to follow system preference
|
|
*
|
|
* Clears user preference and switches to system theme.
|
|
*/
|
|
resetToSystem(): void {
|
|
this.#store.clear();
|
|
this.#theme = this.#getSystemTheme();
|
|
this.#source = 'system';
|
|
this.#applyToDom(this.#theme);
|
|
}
|
|
|
|
// Private helpers
|
|
|
|
/**
|
|
* Detect system theme preference
|
|
* @returns 'dark' if system prefers dark mode, 'light' otherwise
|
|
*/
|
|
#getSystemTheme(): Theme {
|
|
if (typeof window === 'undefined') {
|
|
return 'light';
|
|
}
|
|
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}
|
|
|
|
/**
|
|
* Apply theme to DOM
|
|
* @param theme - Theme to apply
|
|
*/
|
|
#applyToDom(theme: Theme): void {
|
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
}
|
|
|
|
/**
|
|
* Handle system theme change
|
|
* Only updates if currently following system preference
|
|
*/
|
|
#onSystemChange(e: MediaQueryListEvent): void {
|
|
if (this.#source === 'system') {
|
|
this.#theme = e.matches ? 'dark' : 'light';
|
|
this.#applyToDom(this.#theme);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Singleton theme manager instance
|
|
*
|
|
* Use throughout the app for consistent theme state.
|
|
*/
|
|
export const themeManager = new ThemeManager();
|
|
|
|
/**
|
|
* ThemeManager class exported for testing purposes
|
|
* Use the singleton `themeManager` in application code.
|
|
*/
|
|
export { ThemeManager };
|