refactor(features, widgets): update ThemeManager, FontSampler, FontSearch, and SampleList
This commit is contained in:
@@ -1,14 +1,55 @@
|
||||
/**
|
||||
* 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() {
|
||||
@@ -23,35 +64,56 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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';
|
||||
}
|
||||
|
||||
/** Call once in root onMount */
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** Call in root onDestroy */
|
||||
/**
|
||||
* 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;
|
||||
@@ -59,11 +121,18 @@ class ThemeManager {
|
||||
this.#applyToDom(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark themes
|
||||
*/
|
||||
toggle(): void {
|
||||
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
/** Hand control back to OS */
|
||||
/**
|
||||
* Reset to follow system preference
|
||||
*
|
||||
* Clears user preference and switches to system theme.
|
||||
*/
|
||||
resetToSystem(): void {
|
||||
this.#store.clear();
|
||||
this.#theme = this.#getSystemTheme();
|
||||
@@ -71,10 +140,12 @@ class ThemeManager {
|
||||
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';
|
||||
@@ -83,10 +154,18 @@ class ThemeManager {
|
||||
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';
|
||||
@@ -95,5 +174,15 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton — one instance for the whole app
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
// ============================================================
|
||||
// Mock MediaQueryListEvent for system theme change simulations
|
||||
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
|
||||
// ============================================================
|
||||
|
||||
class MockMediaQueryListEvent extends Event {
|
||||
matches: boolean;
|
||||
media: string;
|
||||
|
||||
constructor(type: string, eventInitDict: { matches: boolean; media: string }) {
|
||||
super(type);
|
||||
this.matches = eventInitDict.matches;
|
||||
this.media = eventInitDict.media;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NOW IT'S SAFE TO IMPORT
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { ThemeManager } from './ThemeManager.svelte';
|
||||
|
||||
/**
|
||||
* Test Suite for ThemeManager
|
||||
*
|
||||
* Tests theme management functionality including:
|
||||
* - Initial state from localStorage or system preference
|
||||
* - Theme setting and persistence
|
||||
* - Toggle functionality
|
||||
* - System preference detection and following
|
||||
* - DOM manipulation for theme application
|
||||
* - MediaQueryList listener management
|
||||
*/
|
||||
|
||||
// Storage key used by ThemeManager
|
||||
const STORAGE_KEY = 'glyphdiff:theme';
|
||||
|
||||
// Helper type for MediaQueryList event handler
|
||||
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('ThemeManager', () => {
|
||||
let classListMock: DOMTokenList;
|
||||
let darkClassAdded = false;
|
||||
let mediaQueryListeners: Map<string, Set<MediaQueryListCallback>> = new Map();
|
||||
let matchMediaSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset tracking variables
|
||||
darkClassAdded = false;
|
||||
mediaQueryListeners.clear();
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
|
||||
// Mock documentElement.classList
|
||||
classListMock = {
|
||||
contains: (className: string) => className === 'dark' ? darkClassAdded : false,
|
||||
add: vi.fn((..._classNames: string[]) => {
|
||||
darkClassAdded = true;
|
||||
}),
|
||||
remove: vi.fn((..._classNames: string[]) => {
|
||||
darkClassAdded = false;
|
||||
}),
|
||||
toggle: vi.fn((className: string, force?: boolean) => {
|
||||
if (className === 'dark') {
|
||||
if (force !== undefined) {
|
||||
darkClassAdded = force;
|
||||
} else {
|
||||
darkClassAdded = !darkClassAdded;
|
||||
}
|
||||
return darkClassAdded;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
supports: vi.fn(() => true),
|
||||
entries: vi.fn(() => []),
|
||||
forEach: vi.fn(),
|
||||
keys: vi.fn(() => []),
|
||||
values: vi.fn(() => []),
|
||||
length: 0,
|
||||
item: vi.fn(() => null),
|
||||
replace: vi.fn(() => false),
|
||||
} as unknown as DOMTokenList;
|
||||
|
||||
// Mock document.documentElement
|
||||
if (typeof document !== 'undefined' && document.documentElement) {
|
||||
Object.defineProperty(document.documentElement, 'classList', {
|
||||
configurable: true,
|
||||
get: () => classListMock,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock window.matchMedia with spy to track listeners
|
||||
matchMediaSpy = vi.fn((query: string) => {
|
||||
// Default to light theme (matches = false)
|
||||
const mediaQueryList = {
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (!mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.set(query, new Set());
|
||||
}
|
||||
mediaQueryListeners.get(query)!.add(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.get(query)!.delete(listener);
|
||||
}
|
||||
}),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
return mediaQueryList;
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: matchMediaSpy,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to trigger a MediaQueryList change event
|
||||
*/
|
||||
function triggerSystemThemeChange(isDark: boolean) {
|
||||
const query = '(prefers-color-scheme: dark)';
|
||||
const listeners = mediaQueryListeners.get(query);
|
||||
if (listeners) {
|
||||
const event = new MockMediaQueryListEvent('change', {
|
||||
matches: isDark,
|
||||
media: query,
|
||||
});
|
||||
listeners.forEach(listener => listener.call({ matches: isDark, media: query } as MediaQueryList, event));
|
||||
}
|
||||
}
|
||||
|
||||
describe('Constructor - Initial State', () => {
|
||||
it('should initialize with light theme when localStorage is empty and system prefers light', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should initialize with system dark theme when localStorage is empty', () => {
|
||||
// Mock system prefers dark theme
|
||||
matchMediaSpy.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should initialize with stored light theme from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should initialize with stored dark theme from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should ignore invalid values in localStorage and use system theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('invalid'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should handle null in localStorage as system theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(null));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should be in user-controlled mode when localStorage has a valid theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isUserControlled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be in user-controlled mode when following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isUserControlled).toBe(false);
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init() - Initialization', () => {
|
||||
it('should apply initial theme to DOM on init', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
|
||||
it('should apply dark theme to DOM when initialized with dark theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should set up MediaQueryList listener on init', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
||||
});
|
||||
|
||||
it('should not fail if called multiple times', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => {
|
||||
manager.init();
|
||||
manager.init();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy() - Cleanup', () => {
|
||||
it('should remove MediaQueryList listener on destroy', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.destroy();
|
||||
|
||||
const listeners = mediaQueryListeners.get('(prefers-color-scheme: dark)');
|
||||
expect(listeners?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('should not fail if destroy is called before init', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => {
|
||||
manager.destroy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not fail if destroy is called multiple times', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
expect(() => {
|
||||
manager.destroy();
|
||||
manager.destroy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTheme() - Set Explicit Theme', () => {
|
||||
it('should set theme to light and update source to user', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should set theme to dark and update source to user', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should save theme to localStorage when set', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
||||
});
|
||||
|
||||
it('should apply theme to DOM when set', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should overwrite existing localStorage value', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
||||
});
|
||||
|
||||
it('should handle switching from light to dark', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should handle switching from dark to light', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle() - Toggle Between Themes', () => {
|
||||
it('should toggle from light to dark', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should toggle from dark to light', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should save toggled theme to localStorage', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
||||
});
|
||||
|
||||
it('should apply toggled theme to DOM', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should handle multiple rapid toggles', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
expect(manager.value).toBe('dark');
|
||||
|
||||
manager.toggle();
|
||||
expect(manager.value).toBe('light');
|
||||
|
||||
manager.toggle();
|
||||
expect(manager.value).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetToSystem() - Reset to System Preference', () => {
|
||||
it('should clear localStorage when resetting to system', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('should set source to system after reset', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.source).toBe('system');
|
||||
expect(manager.isUserControlled).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect and apply light system theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
|
||||
it('should detect and apply dark system theme', () => {
|
||||
// Override matchMedia to return dark preference
|
||||
matchMediaSpy.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should apply system theme to DOM on reset', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('System Theme Change Handling', () => {
|
||||
it('should update theme when system changes to dark while following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
});
|
||||
|
||||
it('should update theme when system changes to light while following system', () => {
|
||||
// Start with dark system theme
|
||||
// Keep the listener tracking while overriding matches behavior
|
||||
matchMediaSpy.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (!mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.set(query, new Set());
|
||||
}
|
||||
mediaQueryListeners.get(query)!.add(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.get(query)!.delete(listener);
|
||||
}
|
||||
}),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
expect(manager.value).toBe('dark');
|
||||
|
||||
// Now change to light
|
||||
triggerSystemThemeChange(false);
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
});
|
||||
|
||||
it('should update DOM when system theme changes while following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should NOT update theme when system changes if user has set theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('light'); // User explicitly sets light
|
||||
manager.init();
|
||||
|
||||
// Simulate system changing to dark
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
// Theme should remain light because user set it
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should respond to system changes after resetToSystem', () => {
|
||||
// Start with user-controlled dark theme
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('user');
|
||||
|
||||
// Reset to system (which is light)
|
||||
manager.resetToSystem();
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
|
||||
// Now system changes to dark
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
// Should update because we're back to following system
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should stop responding to system changes after setTheme is called', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
// System changes to dark
|
||||
triggerSystemThemeChange(true);
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('system');
|
||||
|
||||
// User explicitly sets light
|
||||
manager.setTheme('light');
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('user');
|
||||
|
||||
// System changes again
|
||||
triggerSystemThemeChange(false);
|
||||
|
||||
// Should stay light because user set it
|
||||
expect(manager.value).toBe('light');
|
||||
});
|
||||
|
||||
it('should not trigger updates after destroy is called', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.destroy();
|
||||
|
||||
// This should not cause any updates since listener was removed
|
||||
expect(() => {
|
||||
triggerSystemThemeChange(true);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOM Interaction', () => {
|
||||
it('should add dark class when applying dark theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
manager.setTheme('dark');
|
||||
|
||||
// Check toggle was called with force=true for dark
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should remove dark class when applying light theme', () => {
|
||||
// Start with dark
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
// Switch to light
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
|
||||
it('should not add dark class when system prefers light', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getter Properties', () => {
|
||||
it('value getter should return current theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
});
|
||||
|
||||
it('source getter should return "user" when theme is user-controlled', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('source getter should return "system" when following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('isDark getter should return true for dark theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.isDark).toBe(true);
|
||||
});
|
||||
|
||||
it('isDark getter should return false for light theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isDark).toBe(false);
|
||||
});
|
||||
|
||||
it('isUserControlled getter should return true when source is user', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(manager.isUserControlled).toBe(true);
|
||||
});
|
||||
|
||||
it('isUserControlled getter should return false when source is system', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isUserControlled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid setTheme calls', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
manager.setTheme('light');
|
||||
manager.setTheme('dark');
|
||||
manager.setTheme('light');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
|
||||
});
|
||||
|
||||
it('should handle toggle immediately followed by setTheme', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
manager.setTheme('light');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
|
||||
});
|
||||
|
||||
it('should handle setTheme immediately followed by resetToSystem', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle resetToSystem when already following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Safety', () => {
|
||||
it('should accept "light" as valid theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => manager.setTheme('light')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept "dark" as valid theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => manager.setTheme('dark')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,25 +23,10 @@ const { Story } = defineMeta({
|
||||
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import {
|
||||
onDestroy,
|
||||
onMount,
|
||||
} from 'svelte';
|
||||
|
||||
// Current theme state for display
|
||||
const currentTheme = $derived(themeManager.value);
|
||||
const themeSource = $derived(themeManager.source);
|
||||
const isDark = $derived(themeManager.isDark);
|
||||
|
||||
// Initialize themeManager on mount
|
||||
onMount(() => {
|
||||
themeManager.init();
|
||||
});
|
||||
|
||||
// Clean up themeManager when story unmounts
|
||||
onDestroy(() => {
|
||||
themeManager.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
|
||||
@@ -18,9 +18,9 @@ const theme = $derived(themeManager.value);
|
||||
<IconButton onclick={() => themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme">
|
||||
{#snippet icon()}
|
||||
{#if theme === 'light'}
|
||||
<MoonIcon />
|
||||
<MoonIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
||||
{:else}
|
||||
<SunIcon />
|
||||
<SunIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
|
||||
@@ -20,11 +20,18 @@ import {
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/** Font info */
|
||||
/**
|
||||
* Font info
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/** Editable sample text */
|
||||
/**
|
||||
* Sample text
|
||||
*/
|
||||
text: string;
|
||||
/** Position index — drives the staggered entrance delay */
|
||||
/**
|
||||
* Position index
|
||||
* @default 0
|
||||
*/
|
||||
index?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user