feat(ThemeManager): create ThemeManager that uses persistent storage to store preferred user theme
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
import { createPersistentStore } from '$shared/lib';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
type ThemeSource = 'system' | 'user';
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
// Private reactive state
|
||||||
|
#theme = $state<Theme>('light');
|
||||||
|
#source = $state<ThemeSource>('system');
|
||||||
|
#mediaQuery: MediaQueryList | null = null;
|
||||||
|
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
||||||
|
#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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): Theme {
|
||||||
|
return this.#theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
get source(): ThemeSource {
|
||||||
|
return this.#source;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDark(): boolean {
|
||||||
|
return this.#theme === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isUserControlled(): boolean {
|
||||||
|
return this.#source === 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call once in root onMount */
|
||||||
|
init(): void {
|
||||||
|
this.#applyToDom(this.#theme);
|
||||||
|
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
this.#mediaQuery.addEventListener('change', this.#systemChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call in root onDestroy */
|
||||||
|
destroy(): void {
|
||||||
|
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||||
|
this.#mediaQuery = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: Theme): void {
|
||||||
|
this.#source = 'user';
|
||||||
|
this.#theme = theme;
|
||||||
|
this.#store.value = theme;
|
||||||
|
this.#applyToDom(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(): void {
|
||||||
|
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand control back to OS */
|
||||||
|
resetToSystem(): void {
|
||||||
|
this.#store.clear();
|
||||||
|
this.#theme = this.#getSystemTheme();
|
||||||
|
this.#source = 'system';
|
||||||
|
this.#applyToDom(this.#theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Private helpers
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
#getSystemTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
#applyToDom(theme: Theme): void {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
#onSystemChange(e: MediaQueryListEvent): void {
|
||||||
|
if (this.#source === 'system') {
|
||||||
|
this.#theme = e.matches ? 'dark' : 'light';
|
||||||
|
this.#applyToDom(this.#theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton — one instance for the whole app
|
||||||
|
export const themeManager = new ThemeManager();
|
||||||
Reference in New Issue
Block a user