diff --git a/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts index d551017..5a999d4 100644 --- a/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts +++ b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.svelte.ts @@ -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 + * + * + * + * ``` + */ + 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('light'); + /** Whether theme is controlled by user or follows system */ #source = $state('system'); + /** MediaQueryList for detecting system theme changes */ #mediaQuery: MediaQueryList | null = null; + /** Persistent storage for user's theme preference */ #store = createPersistentStore('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 }; diff --git a/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts new file mode 100644 index 0000000..702883a --- /dev/null +++ b/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts @@ -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> = new Map(); + let matchMediaSpy: ReturnType; + + 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(); + }); + }); +}); diff --git a/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.stories.svelte b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.stories.svelte index e3334f7..007a0fd 100644 --- a/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.stories.svelte +++ b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.stories.svelte @@ -23,25 +23,10 @@ const { Story } = defineMeta({ diff --git a/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte index 74823dc..4276ff7 100644 --- a/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte +++ b/src/features/ChangeAppTheme/ui/ThemeSwitch/ThemeSwitch.svelte @@ -18,9 +18,9 @@ const theme = $derived(themeManager.value); themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme"> {#snippet icon()} {#if theme === 'light'} - + {:else} - + {/if} {/snippet} diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte index a9d5d73..1a664f9 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte +++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte @@ -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; } diff --git a/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte b/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte index c950054..4a576e3 100644 --- a/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte +++ b/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte @@ -14,7 +14,74 @@ const { Story } = defineMeta({ }, story: { inline: false }, }, - layout: 'centered', + viewport: { + viewports: { + mobile1: { + name: 'iPhone 5/SE', + styles: { + width: '320px', + height: '568px', + }, + }, + mobile2: { + name: 'iPhone 14 Pro Max', + styles: { + width: '414px', + height: '896px', + }, + }, + tablet: { + name: 'iPad (Portrait)', + styles: { + width: '834px', + height: '1112px', + }, + }, + desktop: { + name: 'Desktop (Small)', + styles: { + width: '1024px', + height: '1280px', + }, + }, + widgetMedium: { + name: 'Widget Medium', + styles: { + width: '768px', + height: '800px', + }, + }, + widgetWide: { + name: 'Widget Wide', + styles: { + width: '1024px', + height: '800px', + }, + }, + widgetExtraWide: { + name: 'Widget Extra Wide', + styles: { + width: '1280px', + height: '800px', + }, + }, + fullWidth: { + name: 'Full Width', + styles: { + width: '100%', + height: '800px', + }, + }, + fullScreen: { + name: 'Full Screen', + styles: { + width: '100%', + height: '100%', + }, + }, + }, + }, + layout: 'padded', }, argTypes: { showFilters: { @@ -31,14 +98,14 @@ let showFiltersClosed = $state(false); let showFiltersOpen = $state(true); - -
+ +
- -
+ +

Filters panel is open and visible

@@ -46,8 +113,8 @@ let showFiltersOpen = $state(true);
- -
+ +

Filters panel is closed - click the slider icon to open

@@ -55,13 +122,13 @@ let showFiltersOpen = $state(true);
- +
- +

Font Browser

@@ -78,8 +145,8 @@ let showFiltersOpen = $state(true);
- -
+ +

Demo Note: Click the slider icon to toggle filters. Use the @@ -90,7 +157,7 @@ let showFiltersOpen = $state(true);

- +

Resize browser to see responsive layout

diff --git a/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte index 07e0be6..6302e25 100644 --- a/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte +++ b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte @@ -11,15 +11,12 @@ import { mapManagerToParams, } from '$features/GetFonts'; import { springySlideFade } from '$shared/lib'; -import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { Button, - Footnote, - IconButton, SearchBar, } from '$shared/ui'; import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal'; -import { onMount } from 'svelte'; +import { untrack } from 'svelte'; import { cubicOut } from 'svelte/easing'; import { Tween, @@ -29,22 +26,17 @@ import { type SlideParams } from 'svelte/transition'; interface Props { /** - * Controllable flag to show/hide filters (bindable) + * Show filters flag + * @default true */ showFilters?: boolean; } let { showFilters = $bindable(true) }: Props = $props(); -onMount(() => { - /** - * The Pairing: - * We "plug" this manager into the global store. - * addBinding returns a function that removes this binding when the component unmounts. - */ - const unbind = unifiedFontStore.addBinding(() => mapManagerToParams(filterManager)); - - return unbind; +$effect(() => { + const params = mapManagerToParams(filterManager); + untrack(() => unifiedFontStore.setParams(params)); }); const transform = new Tween( @@ -67,8 +59,8 @@ function toggleFilters() { } -
-
+
+
-
- {#snippet content({ className })} -
- -
+ + {#snippet content(registerAction)} +
+ {#snippet content({ className })} +
+ +
+ {/snippet} +
{/snippet} -
+ diff --git a/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts index b0f7154..cf25fb5 100644 --- a/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts +++ b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.svelte.ts @@ -1,3 +1,21 @@ +/** + * Layout mode manager for SampleList widget + * + * Manages the display layout (list vs grid) for the sample list widget. + * Persists user preference and provides responsive column calculations. + * + * Layout modes: + * - List: Single column, full-width items + * - Grid: Multi-column with responsive breakpoints + * + * Responsive grid columns: + * - Mobile (< 640px): 1 column + * - Tablet Portrait (640-767px): 1 column + * - Tablet (768-1023px): 2 columns + * - Desktop (1024-1279px): 3 columns + * - Desktop Large (>= 1280px): 4 columns + */ + import { createPersistentStore } from '$shared/lib'; import { responsiveManager } from '$shared/lib'; @@ -16,12 +34,15 @@ const DEFAULT_CONFIG: LayoutConfig = { }; /** - * LayoutManager manages the layout configuration for SampleList widget. - * Handles mode switching between list/grid and responsive column calculation. + * Layout manager for SampleList widget + * + * Handles mode switching between list/grid and responsive column + * calculation. Persists user preference to localStorage. */ class LayoutManager { - // Private reactive state + /** Current layout mode */ #mode = $state(DEFAULT_CONFIG.mode); + /** Persistent storage for layout preference */ #store = createPersistentStore(STORAGE_KEY, DEFAULT_CONFIG); constructor() { @@ -32,31 +53,34 @@ class LayoutManager { } } + /** Current layout mode ('list' or 'grid') */ get mode(): LayoutMode { return this.#mode; } + /** + * Gap between items in pixels + * Responsive: 16px on mobile, 24px on tablet+ + */ get gap(): number { return responsiveManager.isMobile || responsiveManager.isTabletPortrait ? SM_GAP_PX : MD_GAP_PX; } + /** Whether currently in list mode */ get isListMode(): boolean { return this.#mode === 'list'; } + /** Whether currently in grid mode */ get isGridMode(): boolean { return this.#mode === 'grid'; } /** - * Get current number of columns based on mode and screen size. + * Current number of columns based on mode and screen size + * * List mode always uses 1 column. - * Grid mode uses responsive column counts: - * - Mobile: 1 column - * - Tablet Portrait: 1 column - * - Tablet: 2 columns - * - Desktop: 3 columns - * - Desktop Large: 4 columns + * Grid mode uses responsive column counts. */ get columns(): number { if (this.#mode === 'list') { @@ -81,7 +105,7 @@ class LayoutManager { } /** - * Set the layout mode. + * Set the layout mode * @param mode - The new layout mode ('list' or 'grid') */ setMode(mode: LayoutMode): void { @@ -94,14 +118,14 @@ class LayoutManager { } /** - * Toggle between list and grid modes. + * Toggle between list and grid modes */ toggleMode(): void { this.setMode(this.#mode === 'list' ? 'grid' : 'list'); } /** - * Reset to default layout mode. + * Reset to default layout mode */ reset(): void { this.#mode = DEFAULT_CONFIG.mode; @@ -109,5 +133,10 @@ class LayoutManager { } } -// Export a singleton — one instance for the whole app +/** + * Singleton layout manager instance + */ export const layoutManager = new LayoutManager(); + +// Export class for testing purposes +export { LayoutManager }; diff --git a/src/widgets/SampleList/model/stores/layoutStore/layoutStore.test.ts b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.test.ts new file mode 100644 index 0000000..028a174 --- /dev/null +++ b/src/widgets/SampleList/model/stores/layoutStore/layoutStore.test.ts @@ -0,0 +1,381 @@ +/** @vitest-environment jsdom */ + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +// Helper to flush Svelte effects (they run in microtasks) +async function flushEffects() { + await Promise.resolve(); +} + +// Storage key used by LayoutManager +const STORAGE_KEY = 'glyphdiff:sample-list-layout'; + +describe('layoutStore', () => { + // Default viewport for most tests (desktop large - >= 1536px) + const DEFAULT_WIDTH = 1600; + + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + + // Mock window.innerWidth for responsive manager + // Default to desktop large (>= 1536px) + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: DEFAULT_WIDTH, + }); + + // Trigger a resize event to update responsiveManager + window.dispatchEvent(new Event('resize')); + }); + + describe('Initialization', () => { + it('should initialize with default list mode when no saved value', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.mode).toBe('list'); + expect(manager.isListMode).toBe(true); + expect(manager.isGridMode).toBe(false); + }); + + it('should load saved grid mode from localStorage', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ mode: 'grid' })); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.mode).toBe('grid'); + expect(manager.isListMode).toBe(false); + expect(manager.isGridMode).toBe(true); + }); + + it('should load saved list mode from localStorage', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ mode: 'list' })); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.mode).toBe('list'); + expect(manager.isListMode).toBe(true); + expect(manager.isGridMode).toBe(false); + }); + + it('should default to list mode when localStorage has invalid data', async () => { + localStorage.setItem(STORAGE_KEY, 'invalid json'); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.mode).toBe('list'); + }); + + it('should default to list mode when localStorage has empty object', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({})); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.mode).toBe('list'); + }); + }); + + describe('columns', () => { + it('should return 1 column in list mode regardless of screen size', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('list'); + + // At default viewport (1600px - desktop large) + expect(manager.columns).toBe(1); + }); + + describe('grid mode', () => { + it('should return 1 column on mobile (< 640px)', async () => { + Object.defineProperty(window, 'innerWidth', { value: 320, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + await flushEffects(); + + expect(manager.columns).toBe(1); + }); + + it('should return 1 column on tablet portrait (640-767px)', async () => { + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + await flushEffects(); + + expect(manager.columns).toBe(1); + }); + + it('should return 2 columns on tablet (768-1279px)', async () => { + Object.defineProperty(window, 'innerWidth', { value: 900, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + await flushEffects(); + + expect(manager.columns).toBe(2); + }); + + it('should return 3 columns on desktop (1280-1535px)', async () => { + Object.defineProperty(window, 'innerWidth', { value: 1400, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + await flushEffects(); + + expect(manager.columns).toBe(3); + }); + + it('should return 4 columns on desktop large (>= 1536px)', async () => { + Object.defineProperty(window, 'innerWidth', { + value: DEFAULT_WIDTH, + configurable: true, + writable: true, + }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + await flushEffects(); + + expect(manager.columns).toBe(4); + }); + }); + }); + + describe('gap', () => { + it('should return 16px on mobile (< 640px)', async () => { + Object.defineProperty(window, 'innerWidth', { value: 320, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.gap).toBe(16); + }); + + it('should return 16px on tablet portrait (640-767px)', async () => { + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.gap).toBe(16); + }); + + it('should return 24px on tablet and larger', async () => { + Object.defineProperty(window, 'innerWidth', { value: 900, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.gap).toBe(24); + + Object.defineProperty(window, 'innerWidth', { value: 1400, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + expect(manager.gap).toBe(24); + + Object.defineProperty(window, 'innerWidth', { value: DEFAULT_WIDTH, configurable: true, writable: true }); + window.dispatchEvent(new Event('resize')); + await flushEffects(); + expect(manager.gap).toBe(24); + }); + }); + + describe('setMode', () => { + it('should change mode from list to grid', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + expect(manager.mode).toBe('list'); + + manager.setMode('grid'); + + expect(manager.mode).toBe('grid'); + expect(manager.isListMode).toBe(false); + expect(manager.isGridMode).toBe(true); + }); + + it('should change mode from grid to list', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + expect(manager.mode).toBe('grid'); + + manager.setMode('list'); + + expect(manager.mode).toBe('list'); + expect(manager.isListMode).toBe(true); + expect(manager.isGridMode).toBe(false); + }); + + it('should persist mode to localStorage', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + manager.setMode('grid'); + await flushEffects(); + + const stored = localStorage.getItem(STORAGE_KEY); + expect(stored).toBe(JSON.stringify({ mode: 'grid' })); + }); + + it('should not do anything if setting the same mode', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + + // Store the current localStorage value + const storedBefore = localStorage.getItem(STORAGE_KEY); + + manager.setMode('grid'); + + // Mode should still be grid + expect(manager.mode).toBe('grid'); + // localStorage should have the same value (no re-write) + expect(localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + }); + }); + + describe('toggleMode', () => { + it('should toggle from list to grid', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + expect(manager.mode).toBe('list'); + + manager.toggleMode(); + + expect(manager.mode).toBe('grid'); + }); + + it('should toggle from grid to list', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + expect(manager.mode).toBe('grid'); + + manager.toggleMode(); + + expect(manager.mode).toBe('list'); + }); + + it('should persist toggled mode to localStorage', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + manager.toggleMode(); + await flushEffects(); + + const stored = localStorage.getItem(STORAGE_KEY); + expect(stored).toBe(JSON.stringify({ mode: 'grid' })); + }); + }); + + describe('reset', () => { + it('should reset to default list mode', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + expect(manager.mode).toBe('grid'); + + manager.reset(); + + expect(manager.mode).toBe('list'); + }); + + it('should clear localStorage', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + + // Wait for the effect to write to localStorage + await flushEffects(); + + expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify({ mode: 'grid' })); + + manager.reset(); + + expect(localStorage.getItem(STORAGE_KEY)).toBe(null); + }); + + it('should work when already at default mode', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + expect(manager.mode).toBe('list'); + + manager.reset(); + + expect(manager.mode).toBe('list'); + expect(localStorage.getItem(STORAGE_KEY)).toBe(null); + }); + }); + + describe('isListMode and isGridMode', () => { + it('should return correct boolean states for list mode', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.isListMode).toBe(true); + expect(manager.isGridMode).toBe(false); + }); + + it('should return correct boolean states for grid mode', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + manager.setMode('grid'); + + expect(manager.isListMode).toBe(false); + expect(manager.isGridMode).toBe(true); + }); + + it('should update boolean states when mode changes', async () => { + const { LayoutManager } = await import('./layoutStore.svelte'); + const manager = new LayoutManager(); + + expect(manager.isListMode).toBe(true); + expect(manager.isGridMode).toBe(false); + + manager.toggleMode(); + + expect(manager.isListMode).toBe(false); + expect(manager.isGridMode).toBe(true); + + manager.setMode('list'); + + expect(manager.isListMode).toBe(true); + expect(manager.isGridMode).toBe(false); + }); + }); +}); diff --git a/src/widgets/SampleList/ui/LayoutSwitch/LayoutSwitch.svelte b/src/widgets/SampleList/ui/LayoutSwitch/LayoutSwitch.svelte index f70d9ae..0c17d32 100644 --- a/src/widgets/SampleList/ui/LayoutSwitch/LayoutSwitch.svelte +++ b/src/widgets/SampleList/ui/LayoutSwitch/LayoutSwitch.svelte @@ -1,3 +1,7 @@ + - +
@@ -34,13 +101,13 @@ const { Story } = defineMeta({
- +
- +
@@ -52,7 +119,7 @@ const { Story } = defineMeta({
- +
@@ -64,7 +131,7 @@ const { Story } = defineMeta({
- +
@@ -76,7 +143,7 @@ const { Story } = defineMeta({
- +
diff --git a/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte b/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte index ff30b9d..479d406 100644 --- a/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte +++ b/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte @@ -3,36 +3,57 @@ Wraps SampleList with a Section component --> -
- {#snippet headerContent()} - - {/snippet} + + {#snippet content(registerAction)} +
+ {#snippet headerContent()} +
+ + +
+ {/snippet} - {#snippet content({ className })} -
- -
+ {#snippet content({ className })} +
+ +
+ {/snippet} +
{/snippet} -
+ diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 13a31c1..2912990 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -1,5 +1,15 @@ -export { ComparisonSlider } from './ComparisonSlider'; -export { FontSearch } from './FontSearch'; +/** + * Widgets layer + * + * Composed UI blocks that combine features and entities into complete + * user-facing components. + */ + +export { ComparisonView } from './ComparisonView'; +export { + FontSearch, + FontSearchSection, +} from './FontSearch'; export { SampleList, SampleListSection,