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,