feature/project-redesign #28
@@ -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';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
type ThemeSource = 'system' | 'user';
|
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 {
|
class ThemeManager {
|
||||||
// Private reactive state
|
// Private reactive state
|
||||||
|
/** Current theme value ('light' or 'dark') */
|
||||||
#theme = $state<Theme>('light');
|
#theme = $state<Theme>('light');
|
||||||
|
/** Whether theme is controlled by user or follows system */
|
||||||
#source = $state<ThemeSource>('system');
|
#source = $state<ThemeSource>('system');
|
||||||
|
/** MediaQueryList for detecting system theme changes */
|
||||||
#mediaQuery: MediaQueryList | null = null;
|
#mediaQuery: MediaQueryList | null = null;
|
||||||
|
/** Persistent storage for user's theme preference */
|
||||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
||||||
|
/** Bound handler for system theme change events */
|
||||||
#systemChangeHandler = this.#onSystemChange.bind(this);
|
#systemChangeHandler = this.#onSystemChange.bind(this);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -23,35 +64,56 @@ class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current theme value */
|
||||||
get value(): Theme {
|
get value(): Theme {
|
||||||
return this.#theme;
|
return this.#theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Source of current theme ('system' or 'user') */
|
||||||
get source(): ThemeSource {
|
get source(): ThemeSource {
|
||||||
return this.#source;
|
return this.#source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether dark theme is active */
|
||||||
get isDark(): boolean {
|
get isDark(): boolean {
|
||||||
return this.#theme === 'dark';
|
return this.#theme === 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether theme is controlled by user (not following system) */
|
||||||
get isUserControlled(): boolean {
|
get isUserControlled(): boolean {
|
||||||
return this.#source === 'user';
|
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 {
|
init(): void {
|
||||||
this.#applyToDom(this.#theme);
|
this.#applyToDom(this.#theme);
|
||||||
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
this.#mediaQuery.addEventListener('change', this.#systemChangeHandler);
|
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 {
|
destroy(): void {
|
||||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||||
this.#mediaQuery = null;
|
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 {
|
setTheme(theme: Theme): void {
|
||||||
this.#source = 'user';
|
this.#source = 'user';
|
||||||
this.#theme = theme;
|
this.#theme = theme;
|
||||||
@@ -59,11 +121,18 @@ class ThemeManager {
|
|||||||
this.#applyToDom(theme);
|
this.#applyToDom(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between light and dark themes
|
||||||
|
*/
|
||||||
toggle(): void {
|
toggle(): void {
|
||||||
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
|
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 {
|
resetToSystem(): void {
|
||||||
this.#store.clear();
|
this.#store.clear();
|
||||||
this.#theme = this.#getSystemTheme();
|
this.#theme = this.#getSystemTheme();
|
||||||
@@ -71,10 +140,12 @@ class ThemeManager {
|
|||||||
this.#applyToDom(this.#theme);
|
this.#applyToDom(this.#theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------
|
|
||||||
// Private helpers
|
// Private helpers
|
||||||
// -------------------------
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect system theme preference
|
||||||
|
* @returns 'dark' if system prefers dark mode, 'light' otherwise
|
||||||
|
*/
|
||||||
#getSystemTheme(): Theme {
|
#getSystemTheme(): Theme {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return 'light';
|
return 'light';
|
||||||
@@ -83,10 +154,18 @@ class ThemeManager {
|
|||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply theme to DOM
|
||||||
|
* @param theme - Theme to apply
|
||||||
|
*/
|
||||||
#applyToDom(theme: Theme): void {
|
#applyToDom(theme: Theme): void {
|
||||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle system theme change
|
||||||
|
* Only updates if currently following system preference
|
||||||
|
*/
|
||||||
#onSystemChange(e: MediaQueryListEvent): void {
|
#onSystemChange(e: MediaQueryListEvent): void {
|
||||||
if (this.#source === 'system') {
|
if (this.#source === 'system') {
|
||||||
this.#theme = e.matches ? 'dark' : 'light';
|
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();
|
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">
|
<script lang="ts">
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
import { themeManager } from '$features/ChangeAppTheme';
|
||||||
import {
|
|
||||||
onDestroy,
|
|
||||||
onMount,
|
|
||||||
} from 'svelte';
|
|
||||||
|
|
||||||
// Current theme state for display
|
// Current theme state for display
|
||||||
const currentTheme = $derived(themeManager.value);
|
const currentTheme = $derived(themeManager.value);
|
||||||
const themeSource = $derived(themeManager.source);
|
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>
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story name="Default">
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ const theme = $derived(themeManager.value);
|
|||||||
<IconButton onclick={() => themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme">
|
<IconButton onclick={() => themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
{#if theme === 'light'}
|
{#if theme === 'light'}
|
||||||
<MoonIcon />
|
<MoonIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
||||||
{:else}
|
{:else}
|
||||||
<SunIcon />
|
<SunIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -20,11 +20,18 @@ import {
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Font info */
|
/**
|
||||||
|
* Font info
|
||||||
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
/** Editable sample text */
|
/**
|
||||||
|
* Sample text
|
||||||
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
/** Position index — drives the staggered entrance delay */
|
/**
|
||||||
|
* Position index
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
index?: number;
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,74 @@ const { Story } = defineMeta({
|
|||||||
},
|
},
|
||||||
story: { inline: false },
|
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: {
|
argTypes: {
|
||||||
showFilters: {
|
showFilters: {
|
||||||
@@ -31,14 +98,14 @@ let showFiltersClosed = $state(false);
|
|||||||
let showFiltersOpen = $state(true);
|
let showFiltersOpen = $state(true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story name="Default" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-2xl">
|
<div class="w-full max-w-3xl">
|
||||||
<FontSearch bind:showFilters={showFiltersDefault} />
|
<FontSearch bind:showFilters={showFiltersDefault} />
|
||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Filters Open">
|
<Story name="Filters Open" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-2xl">
|
<div class="w-full max-w-3xl">
|
||||||
<FontSearch bind:showFilters={showFiltersOpen} />
|
<FontSearch bind:showFilters={showFiltersOpen} />
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8 text-center">
|
||||||
<p class="text-text-muted text-sm">Filters panel is open and visible</p>
|
<p class="text-text-muted text-sm">Filters panel is open and visible</p>
|
||||||
@@ -46,8 +113,8 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Filters Closed">
|
<Story name="Filters Closed" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-2xl">
|
<div class="w-full max-w-3xl">
|
||||||
<FontSearch bind:showFilters={showFiltersClosed} />
|
<FontSearch bind:showFilters={showFiltersClosed} />
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8 text-center">
|
||||||
<p class="text-text-muted text-sm">Filters panel is closed - click the slider icon to open</p>
|
<p class="text-text-muted text-sm">Filters panel is closed - click the slider icon to open</p>
|
||||||
@@ -55,13 +122,13 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Full Width">
|
<Story name="Full Width" parameters={{ globals: { viewport: 'fullWidth' } }}>
|
||||||
<div class="w-full px-8">
|
<div class="w-full px-8">
|
||||||
<FontSearch />
|
<FontSearch />
|
||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="In Context" tags={['!autodocs']}>
|
<Story name="In Context" tags={['!autodocs']} parameters={{ globals: { viewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-3xl p-8 space-y-6">
|
<div class="w-full max-w-3xl p-8 space-y-6">
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-4xl font-bold mb-2">Font Browser</h1>
|
<h1 class="text-4xl font-bold mb-2">Font Browser</h1>
|
||||||
@@ -78,8 +145,8 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With Filters Demo">
|
<Story name="With Filters Demo" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-2xl">
|
<div class="w-full max-w-3xl">
|
||||||
<div class="mb-4 p-4 bg-background-40 rounded-lg">
|
<div class="mb-4 p-4 bg-background-40 rounded-lg">
|
||||||
<p class="text-sm text-text-muted">
|
<p class="text-sm text-text-muted">
|
||||||
<strong class="text-foreground">Demo Note:</strong> Click the slider icon to toggle filters. Use the
|
<strong class="text-foreground">Demo Note:</strong> Click the slider icon to toggle filters. Use the
|
||||||
@@ -90,7 +157,7 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Responsive Behavior">
|
<Story name="Responsive Behavior" parameters={{ globals: { viewport: 'fullWidth' } }}>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="mb-4 text-center">
|
<div class="mb-4 text-center">
|
||||||
<p class="text-text-muted text-sm">Resize browser to see responsive layout</p>
|
<p class="text-text-muted text-sm">Resize browser to see responsive layout</p>
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ import {
|
|||||||
mapManagerToParams,
|
mapManagerToParams,
|
||||||
} from '$features/GetFonts';
|
} from '$features/GetFonts';
|
||||||
import { springySlideFade } from '$shared/lib';
|
import { springySlideFade } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Footnote,
|
|
||||||
IconButton,
|
|
||||||
SearchBar,
|
SearchBar,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
|
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
import { onMount } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import {
|
import {
|
||||||
Tween,
|
Tween,
|
||||||
@@ -29,22 +26,17 @@ import { type SlideParams } from 'svelte/transition';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Controllable flag to show/hide filters (bindable)
|
* Show filters flag
|
||||||
|
* @default true
|
||||||
*/
|
*/
|
||||||
showFilters?: boolean;
|
showFilters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { showFilters = $bindable(true) }: Props = $props();
|
let { showFilters = $bindable(true) }: Props = $props();
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
/**
|
const params = mapManagerToParams(filterManager);
|
||||||
* The Pairing:
|
untrack(() => unifiedFontStore.setParams(params));
|
||||||
* 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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const transform = new Tween(
|
const transform = new Tween(
|
||||||
@@ -67,8 +59,8 @@ function toggleFilters() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 border-b border-t border-[#1a1a1a]/5 dark:border-white/10">
|
<div class="flex flex-col gap-3 border-b border-t border-swiss-black/5 dark:border-white/10">
|
||||||
<div class="relative w-full flex border-b border-[#1a1a1a]/5 dark:border-white/10 py-4 md:py-6">
|
<div class="relative w-full flex flex-col md:flex-row gap-y-4 border-b border-swiss-black/5 dark:border-white/10 py-4 md:py-6">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
id="font-search"
|
id="font-search"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|||||||
@@ -3,29 +3,45 @@
|
|||||||
Wraps FontSearch with a Section component
|
Wraps FontSearch with a Section component
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { handleTitleStatusChanged } from '$entities/Breadcrumb';
|
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Section } from '$shared/ui';
|
import { Section } from '$shared/ui';
|
||||||
import { getContext } from 'svelte';
|
import {
|
||||||
|
getContext,
|
||||||
|
untrack,
|
||||||
|
} from 'svelte';
|
||||||
import FontSearch from '../FontSearch/FontSearch.svelte';
|
import FontSearch from '../FontSearch/FontSearch.svelte';
|
||||||
|
|
||||||
let isExpanded = $state(true);
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||||
|
let isExpanded = $state(!isMobileOrTabletPortrait);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isMobileOrTabletPortrait) {
|
||||||
|
untrack(() => {
|
||||||
|
isExpanded = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Section
|
<NavigationWrapper index={1} title="Query">
|
||||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
{#snippet content(registerAction)}
|
||||||
index={2}
|
<Section
|
||||||
id="query_module"
|
class="pt-16 md:pt-24 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
onTitleStatusChange={handleTitleStatusChanged}
|
index={0}
|
||||||
title="Query Module"
|
id="query_module"
|
||||||
headerTitle="query_matrix"
|
title="Query Module"
|
||||||
headerSubtitle="active_nodes:"
|
headerTitle="query_matrix"
|
||||||
>
|
headerSubtitle="active_nodes:"
|
||||||
{#snippet content({ className })}
|
headerAction={registerAction}
|
||||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
>
|
||||||
<FontSearch bind:showFilters={isExpanded} />
|
{#snippet content({ className })}
|
||||||
</div>
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<FontSearch bind:showFilters={isExpanded} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Section>
|
</NavigationWrapper>
|
||||||
|
|||||||
@@ -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 { createPersistentStore } from '$shared/lib';
|
||||||
import { responsiveManager } from '$shared/lib';
|
import { responsiveManager } from '$shared/lib';
|
||||||
|
|
||||||
@@ -16,12 +34,15 @@ const DEFAULT_CONFIG: LayoutConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LayoutManager manages the layout configuration for SampleList widget.
|
* Layout manager for SampleList widget
|
||||||
* Handles mode switching between list/grid and responsive column calculation.
|
*
|
||||||
|
* Handles mode switching between list/grid and responsive column
|
||||||
|
* calculation. Persists user preference to localStorage.
|
||||||
*/
|
*/
|
||||||
class LayoutManager {
|
class LayoutManager {
|
||||||
// Private reactive state
|
/** Current layout mode */
|
||||||
#mode = $state<LayoutMode>(DEFAULT_CONFIG.mode);
|
#mode = $state<LayoutMode>(DEFAULT_CONFIG.mode);
|
||||||
|
/** Persistent storage for layout preference */
|
||||||
#store = createPersistentStore<LayoutConfig>(STORAGE_KEY, DEFAULT_CONFIG);
|
#store = createPersistentStore<LayoutConfig>(STORAGE_KEY, DEFAULT_CONFIG);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -32,31 +53,34 @@ class LayoutManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current layout mode ('list' or 'grid') */
|
||||||
get mode(): LayoutMode {
|
get mode(): LayoutMode {
|
||||||
return this.#mode;
|
return this.#mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gap between items in pixels
|
||||||
|
* Responsive: 16px on mobile, 24px on tablet+
|
||||||
|
*/
|
||||||
get gap(): number {
|
get gap(): number {
|
||||||
return responsiveManager.isMobile || responsiveManager.isTabletPortrait ? SM_GAP_PX : MD_GAP_PX;
|
return responsiveManager.isMobile || responsiveManager.isTabletPortrait ? SM_GAP_PX : MD_GAP_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether currently in list mode */
|
||||||
get isListMode(): boolean {
|
get isListMode(): boolean {
|
||||||
return this.#mode === 'list';
|
return this.#mode === 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether currently in grid mode */
|
||||||
get isGridMode(): boolean {
|
get isGridMode(): boolean {
|
||||||
return this.#mode === 'grid';
|
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.
|
* List mode always uses 1 column.
|
||||||
* Grid mode uses responsive column counts:
|
* Grid mode uses responsive column counts.
|
||||||
* - Mobile: 1 column
|
|
||||||
* - Tablet Portrait: 1 column
|
|
||||||
* - Tablet: 2 columns
|
|
||||||
* - Desktop: 3 columns
|
|
||||||
* - Desktop Large: 4 columns
|
|
||||||
*/
|
*/
|
||||||
get columns(): number {
|
get columns(): number {
|
||||||
if (this.#mode === 'list') {
|
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')
|
* @param mode - The new layout mode ('list' or 'grid')
|
||||||
*/
|
*/
|
||||||
setMode(mode: LayoutMode): void {
|
setMode(mode: LayoutMode): void {
|
||||||
@@ -94,14 +118,14 @@ class LayoutManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle between list and grid modes.
|
* Toggle between list and grid modes
|
||||||
*/
|
*/
|
||||||
toggleMode(): void {
|
toggleMode(): void {
|
||||||
this.setMode(this.#mode === 'list' ? 'grid' : 'list');
|
this.setMode(this.#mode === 'list' ? 'grid' : 'list');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset to default layout mode.
|
* Reset to default layout mode
|
||||||
*/
|
*/
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.#mode = DEFAULT_CONFIG.mode;
|
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 const layoutManager = new LayoutManager();
|
||||||
|
|
||||||
|
// Export class for testing purposes
|
||||||
|
export { LayoutManager };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<!--
|
||||||
|
Component: LayoutSwitch
|
||||||
|
Toggles between list and grid layout modes
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ButtonGroup } from '$shared/ui';
|
import { ButtonGroup } from '$shared/ui';
|
||||||
import { IconButton } from '$shared/ui';
|
import { IconButton } from '$shared/ui';
|
||||||
@@ -6,6 +10,9 @@ import ListIcon from '@lucide/svelte/icons/stretch-horizontal';
|
|||||||
import { layoutManager } from '../../model';
|
import { layoutManager } from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,73 @@ const { Story } = defineMeta({
|
|||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
layout: 'fullscreen',
|
layout: 'fullscreen',
|
||||||
|
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%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
// This component uses internal stores, so no direct props to document
|
// This component uses internal stores, so no direct props to document
|
||||||
@@ -22,7 +89,7 @@ const { Story } = defineMeta({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story name="Default" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -34,13 +101,13 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Full Page">
|
<Story name="Full Page" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<SampleList />
|
<SampleList />
|
||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With Typography Controls">
|
<Story name="With Typography Controls" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -52,7 +119,7 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Custom Text">
|
<Story name="Custom Text" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -64,7 +131,7 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Pagination Info">
|
<Story name="Pagination Info" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -76,7 +143,7 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Responsive Layout">
|
<Story name="Responsive Layout" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-6xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-6xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
|
|||||||
@@ -3,36 +3,57 @@
|
|||||||
Wraps SampleList with a Section component
|
Wraps SampleList with a Section component
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { handleTitleStatusChanged } from '$entities/Breadcrumb';
|
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||||
|
import { unifiedFontStore } from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Section } from '$shared/ui';
|
|
||||||
import {
|
import {
|
||||||
type Snippet,
|
Label,
|
||||||
getContext,
|
Section,
|
||||||
} from 'svelte';
|
} from '$shared/ui';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { layoutManager } from '../../model';
|
||||||
import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte';
|
import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte';
|
||||||
import SampleList from '../SampleList/SampleList.svelte';
|
import SampleList from '../SampleList/SampleList.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Section index
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { index }: Props = $props();
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Section
|
<NavigationWrapper index={2} title="Samples">
|
||||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
{#snippet content(registerAction)}
|
||||||
index={3}
|
<Section
|
||||||
id="sample_set"
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
onTitleStatusChange={handleTitleStatusChanged}
|
{index}
|
||||||
title="Sample Set"
|
id="sample_set"
|
||||||
headerTitle="visual_output"
|
title="Sample Set"
|
||||||
headerSubtitle="render_engine:"
|
headerTitle="visual_output"
|
||||||
>
|
headerSubtitle="items_total: {unifiedFontStore.pagination.total ?? 0}"
|
||||||
{#snippet headerContent()}
|
headerAction={registerAction}
|
||||||
<LayoutSwitch />
|
>
|
||||||
{/snippet}
|
{#snippet headerContent()}
|
||||||
|
<div class="flex items-center gap-3 md:gap-4">
|
||||||
|
<div class="hidden md:flex items-center gap-2 mr-4">
|
||||||
|
<Label variant="muted" size="sm">view_mode: </Label>
|
||||||
|
<Label variant="default" size="sm" bold>{layoutManager.mode}</Label>
|
||||||
|
</div>
|
||||||
|
<LayoutSwitch />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet content({ className })}
|
{#snippet content({ className })}
|
||||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
<SampleList />
|
<SampleList />
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Section>
|
</NavigationWrapper>
|
||||||
|
|||||||
@@ -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 {
|
export {
|
||||||
SampleList,
|
SampleList,
|
||||||
SampleListSection,
|
SampleListSection,
|
||||||
|
|||||||
Reference in New Issue
Block a user