Refactor/reacrhitecture to fsd+ #49

Merged
ilia merged 70 commits from refactor/reacrhitecture-to-fsd+ into main 2026-06-03 09:55:47 +00:00
13 changed files with 107 additions and 46 deletions
Showing only changes of commit 0b675635b3 - Show all commits
+6 -6
View File
@@ -1,21 +1,21 @@
export { mapAppliedFiltersToParams } from './lib'; export { mapAppliedFiltersToParams } from './lib';
export { export {
appliedFilterStore,
/**
* Filter Store
*/
availableFilterStore,
/** /**
* Filter Manager * Filter Manager
*/ */
createAppliedFilterStore, createAppliedFilterStore,
/**
* Lazy store accessors
*/
getAppliedFilterStore,
getAvailableFilterStore,
getSortStore,
/** /**
* Sort Store * Sort Store
*/ */
SORT_MAP, SORT_MAP,
SORT_OPTIONS, SORT_OPTIONS,
sortStore,
startFilterBindings, startFilterBindings,
} from './model'; } from './model';
+10 -10
View File
@@ -14,9 +14,9 @@ export type {
*/ */
export { export {
/** /**
* Low-level property selection store * Lazy accessor for the app-wide filter-metadata store
*/ */
availableFilterStore, getAvailableFilterStore,
} from './store/availableFilterStore/availableFilterStore.svelte'; } from './store/availableFilterStore/availableFilterStore.svelte';
/** /**
@@ -27,14 +27,14 @@ export {
* Reactive interface returned by `createAppliedFilterStore` * Reactive interface returned by `createAppliedFilterStore`
*/ */
type AppliedFilterStore, type AppliedFilterStore,
/**
* High-level manager for syncing search and filters
*/
appliedFilterStore,
/** /**
* Factory for constructing a filter manager instance * Factory for constructing a filter manager instance
*/ */
createAppliedFilterStore, createAppliedFilterStore,
/**
* Lazy accessor for the app-wide filter manager
*/
getAppliedFilterStore,
} from './store/appliedFilterStore/appliedFilterStore.svelte'; } from './store/appliedFilterStore/appliedFilterStore.svelte';
/** /**
@@ -47,6 +47,10 @@ export { startFilterBindings } from './store/bindings.svelte';
* Sorting logic * Sorting logic
*/ */
export { export {
/**
* Lazy accessor for the app-wide sort store
*/
getSortStore,
/** /**
* Map of human-readable labels to API sort keys * Map of human-readable labels to API sort keys
*/ */
@@ -63,8 +67,4 @@ export {
* UI model for a single sort option * UI model for a single sort option
*/ */
type SortOption, type SortOption,
/**
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore/sortStore.svelte'; } from './store/sortStore/sortStore.svelte';
@@ -129,14 +129,23 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>; export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
let _appliedFilterStore: AppliedFilterStore | undefined;
/** /**
* App-wide filter manager singleton. * App-wide filter manager, created on first access.
* *
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring * Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
* lives in `./bindings.svelte` and populates groups once backend filter * lives in `./bindings.svelte` and populates groups once backend filter
* metadata arrives. * metadata arrives.
*/ */
export const appliedFilterStore = createAppliedFilterStore({ export function getAppliedFilterStore(): AppliedFilterStore {
queryValue: '', return (_appliedFilterStore ??= createAppliedFilterStore<string>({
groups: [], queryValue: '',
}); groups: [],
}));
}
// test-only reset, so specs don't share filter/selection state
export function __resetAppliedFilterStore() {
_appliedFilterStore = undefined;
}
@@ -126,7 +126,18 @@ export class AvailableFilterStore {
} }
} }
let _availableFilterStore: AvailableFilterStore | undefined;
/** /**
* Singleton instance * App-wide filter-metadata store, created on first access. Lazy so the
* QueryObserver isn't constructed at module load.
*/ */
export const availableFilterStore = new AvailableFilterStore(); export function getAvailableFilterStore(): AvailableFilterStore {
return (_availableFilterStore ??= new AvailableFilterStore());
}
// test-only reset, so specs don't share a live observer
export function __resetAvailableFilterStore() {
_availableFilterStore?.destroy();
_availableFilterStore = undefined;
}
@@ -13,11 +13,15 @@ import { getFontCatalog } from '$entities/Font/model';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams'; import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups'; import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte'; import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { sortStore } from './sortStore/sortStore.svelte'; import { getSortStore } from './sortStore/sortStore.svelte';
export function startFilterBindings(): () => void { export function startFilterBindings(): () => void {
const appliedFilterStore = getAppliedFilterStore();
const availableFilterStore = getAvailableFilterStore();
const sortStore = getSortStore();
const stop = $effect.root(() => { const stop = $effect.root(() => {
$effect(() => { $effect(() => {
const dynamicFilters = availableFilterStore.filters; const dynamicFilters = availableFilterStore.filters;
@@ -44,4 +44,18 @@ export function createSortStore(initial: SortOption = 'Popularity') {
}; };
} }
export const sortStore = createSortStore(); export type SortStore = ReturnType<typeof createSortStore>;
let _sortStore: SortStore | undefined;
/**
* App-wide sort store, created on first access.
*/
export function getSortStore(): SortStore {
return (_sortStore ??= createSortStore());
}
// test-only reset, so specs don't share selection state
export function __resetSortStore() {
_sortStore = undefined;
}
@@ -1,4 +1,5 @@
import { import {
afterEach,
describe, describe,
expect, expect,
it, it,
@@ -7,8 +8,9 @@ import {
SORT_MAP, SORT_MAP,
SORT_OPTIONS, SORT_OPTIONS,
type SortOption, type SortOption,
__resetSortStore,
createSortStore, createSortStore,
sortStore, getSortStore,
} from './sortStore.svelte'; } from './sortStore.svelte';
describe('createSortStore', () => { describe('createSortStore', () => {
@@ -51,14 +53,24 @@ describe('createSortStore', () => {
}); });
}); });
describe('sortStore singleton', () => { describe('getSortStore singleton', () => {
afterEach(() => {
__resetSortStore();
});
it('returns the same instance across calls', () => {
expect(getSortStore()).toBe(getSortStore());
});
it('exposes the same shape as a factory instance', () => { it('exposes the same shape as a factory instance', () => {
const sortStore = getSortStore();
expect(typeof sortStore.value).toBe('string'); expect(typeof sortStore.value).toBe('string');
expect(typeof sortStore.apiValue).toBe('string'); expect(typeof sortStore.apiValue).toBe('string');
expect(typeof sortStore.set).toBe('function'); expect(typeof sortStore.set).toBe('function');
}); });
it('accepts all SORT_OPTIONS as valid set() inputs', () => { it('accepts all SORT_OPTIONS as valid set() inputs', () => {
const sortStore = getSortStore();
for (const option of SORT_OPTIONS) { for (const option of SORT_OPTIONS) {
sortStore.set(option); sortStore.set(option);
expect(sortStore.value).toBe(option); expect(sortStore.value).toBe(option);
@@ -4,10 +4,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import { FilterGroup } from '$shared/ui'; import { FilterGroup } from '$shared/ui';
import { appliedFilterStore } from '../../model'; import { getAppliedFilterStore } from '../../model';
const appliedFilterStore = getAppliedFilterStore();
const groups = $derived(appliedFilterStore.groups);
</script> </script>
{#each appliedFilterStore.groups as group (group.id)} {#each groups as group (group.id)}
<FilterGroup <FilterGroup
displayedLabel={group.label} displayedLabel={group.label}
filter={group.instance} filter={group.instance}
@@ -1,6 +1,6 @@
import { import {
appliedFilterStore, getAppliedFilterStore,
availableFilterStore, getAvailableFilterStore,
} from '$features/FilterAndSortFonts'; } from '$features/FilterAndSortFonts';
import { import {
render, render,
@@ -12,8 +12,8 @@ import Filters from './Filters.svelte';
describe('Filters', () => { describe('Filters', () => {
beforeEach(() => { beforeEach(() => {
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us // Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
appliedFilterStore.setGroups([]); getAppliedFilterStore().setGroups([]);
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]); vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]);
}); });
afterEach(() => { afterEach(() => {
@@ -28,7 +28,7 @@ describe('Filters', () => {
}); });
it('renders a label for each filter group', () => { it('renders a label for each filter group', () => {
appliedFilterStore.setGroups([ getAppliedFilterStore().setGroups([
{ id: 'cat', label: 'Categories', properties: [] }, { id: 'cat', label: 'Categories', properties: [] },
{ id: 'prov', label: 'Font Providers', properties: [] }, { id: 'prov', label: 'Font Providers', properties: [] },
]); ]);
@@ -38,7 +38,7 @@ describe('Filters', () => {
}); });
it('renders filter properties within groups', () => { it('renders filter properties within groups', () => {
appliedFilterStore.setGroups([ getAppliedFilterStore().setGroups([
{ {
id: 'cat', id: 'cat',
label: 'Category', label: 'Category',
@@ -54,7 +54,7 @@ describe('Filters', () => {
}); });
it('renders multiple groups with their properties', () => { it('renders multiple groups with their properties', () => {
appliedFilterStore.setGroups([ getAppliedFilterStore().setGroups([
{ {
id: 'cat', id: 'cat',
label: 'Category', label: 'Category',
@@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { import {
SORT_OPTIONS, SORT_OPTIONS,
appliedFilterStore, getAppliedFilterStore,
sortStore, getSortStore,
} from '../../model'; } from '../../model';
interface Props { interface Props {
@@ -30,6 +30,10 @@ const {
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait); const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
const appliedFilterStore = getAppliedFilterStore();
const sortStore = getSortStore();
const sortValue = $derived(sortStore.value);
function handleReset() { function handleReset() {
appliedFilterStore.deselectAllGlobal(); appliedFilterStore.deselectAllGlobal();
} }
@@ -53,7 +57,7 @@ function handleReset() {
<Button <Button
variant="ghost" variant="ghost"
size={isMobileOrTabletPortrait ? 'xs' : 'sm'} size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option} active={sortValue === option}
onclick={() => sortStore.set(option)} onclick={() => sortStore.set(option)}
class="tracking-wide px-0" class="tracking-wide px-0"
> >
@@ -5,8 +5,10 @@
propagates the value into fontCatalogStore. propagates the value into fontCatalogStore.
--> -->
<script lang="ts"> <script lang="ts">
import { appliedFilterStore } from '$features/FilterAndSortFonts'; import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
import { SearchBar } from '$shared/ui'; import { SearchBar } from '$shared/ui';
const appliedFilterStore = getAppliedFilterStore();
</script> </script>
<div class="p-6 border-b border-subtle"> <div class="p-6 border-b border-subtle">
@@ -1,4 +1,4 @@
import { appliedFilterStore } from '$features/FilterAndSortFonts'; import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
import { import {
render, render,
screen, screen,
@@ -7,7 +7,7 @@ import Search from './Search.svelte';
describe('Search', () => { describe('Search', () => {
beforeEach(() => { beforeEach(() => {
appliedFilterStore.queryValue = ''; getAppliedFilterStore().queryValue = '';
}); });
describe('Rendering', () => { describe('Rendering', () => {
@@ -24,7 +24,7 @@ describe('Search', () => {
describe('Value binding', () => { describe('Value binding', () => {
it('reflects appliedFilterStore.queryValue as initial value', () => { it('reflects appliedFilterStore.queryValue as initial value', () => {
appliedFilterStore.queryValue = 'Inter'; getAppliedFilterStore().queryValue = 'Inter';
render(Search); render(Search);
expect(screen.getByRole('textbox')).toHaveValue('Inter'); expect(screen.getByRole('textbox')).toHaveValue('Inter');
}); });
@@ -6,7 +6,7 @@
import { import {
FilterControls, FilterControls,
Filters, Filters,
appliedFilterStore, getAppliedFilterStore,
} from '$features/FilterAndSortFonts'; } from '$features/FilterAndSortFonts';
import { springySlideFade } from '$shared/lib'; import { springySlideFade } from '$shared/lib';
import { import {
@@ -31,6 +31,8 @@ interface Props {
let { showFilters = $bindable(true) }: Props = $props(); let { showFilters = $bindable(true) }: Props = $props();
const appliedFilterStore = getAppliedFilterStore();
const transform = new Tween( const transform = new Tween(
{ scale: 1, rotate: 0 }, { scale: 1, rotate: 0 },
{ duration: 250, easing: cubicOut }, { duration: 250, easing: cubicOut },