refactor(GetFonts): restructure filter API and add sort store

This commit is contained in:
Ilia Mashkov
2026-03-02 22:19:59 +03:00
parent 0dd08874bc
commit efe1b4f9df
10 changed files with 1073 additions and 96 deletions

View File

@@ -0,0 +1,85 @@
/**
* Proxy API filters
*
* Fetches filter metadata from GlyphDiff proxy API.
* Provides type-safe response handling.
*
* @see https://api.glyphdiff.com/api/v1/filters
*/
import { api } from '$shared/api/api';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
/**
* Filter metadata type from backend
*/
export interface FilterMetadata {
/** Filter ID (e.g., "providers", "categories", "subsets") */
id: string;
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
name: string;
/** Filter description */
description: string;
/** Filter type */
type: 'enum' | 'string' | 'array';
/** Available filter options */
options: FilterOption[];
}
/**
* Filter option type
*/
export interface FilterOption {
/** Option ID (e.g., "google", "serif", "latin") */
id: string;
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
name: string;
/** Option value (e.g., "google", "serif", "latin") */
value: string;
/** Number of fonts with this value */
count: number;
}
/**
* Proxy filters API response
*/
export interface ProxyFiltersResponse {
/** Array of filter metadata */
filters: FilterMetadata[];
}
/**
* Fetch filters from proxy API
*
* @returns Promise resolving to array of filter metadata
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all filters
* const filters = await fetchProxyFilters();
*
* console.log(filters); // [
* // { id: "providers", name: "Font Providers", options: [...] },
* // { id: "categories", name: "Categories", options: [...] },
* // { id: "subsets", name: "Character Subsets", options: [...] }
* // ]
* ```
*/
export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
const response = await api.get<FilterMetadata[]>(PROXY_API_URL);
if (!response.data || !Array.isArray(response.data)) {
throw new Error('Proxy API returned invalid response');
}
return response.data;
}

View File

@@ -0,0 +1 @@
export * from './filters/filters';

View File

@@ -4,14 +4,16 @@ export {
mapManagerToParams,
} from './lib';
export {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from './model/const/const';
export { filterManager } from './model/state/manager.svelte';
export {
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
} from './model/store/sortStore.svelte';
export {
FilterControls,
Filters,

View File

@@ -1,14 +1,40 @@
/**
* Filter manager for font filtering
*
* Manages multiple filter groups (providers, categories, subsets)
* with debounced search input. Provides reactive state for filter
* selections and convenience methods for bulk operations.
*
* @example
* ```ts
* const manager = createFilterManager({
* queryValue: '',
* groups: [
* { id: 'providers', label: 'Provider', properties: [...] },
* { id: 'categories', label: 'Category', properties: [...] }
* ]
* });
*
* $: searchQuery = manager.debouncedQueryValue;
* $: hasFilters = manager.hasAnySelection;
* ```
*/
import { createFilter } from '$shared/lib';
import { createDebouncedState } from '$shared/lib/helpers';
import type { FilterConfig } from '../../model';
import type {
FilterConfig,
FilterGroupConfig,
} from '../../model';
/**
* Create a filter manager instance.
* - Uses debounce to update search query for better performance.
* - Manages filter instances for each group.
* Creates a filter manager instance
*
* @param config - Configuration for the filter manager.
* @returns - An instance of the filter manager.
* Manages multiple filter groups with debounced search. Each group
* contains filterable properties that can be selected/deselected.
*
* @param config - Configuration with query value and filter groups
* @returns Filter manager instance with reactive state and methods
*/
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
@@ -28,37 +54,68 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
);
return {
// Getter for queryValue (immediate value for UI)
/**
* Replace all filter groups with new config
* Used when dynamic filter data loads from backend
*/
setGroups(newGroups: FilterGroupConfig<TValue>[]) {
groups.length = 0;
groups.push(
...newGroups.map(g => ({
id: g.id,
label: g.label,
instance: createFilter({ properties: g.properties }),
})),
);
},
/**
* Current search query value (immediate, for UI binding)
* Updates instantly as user types
*/
get queryValue() {
return search.immediate;
},
// Setter for queryValue
/**
* Set the search query value
*/
set queryValue(value) {
search.immediate = value;
},
// Getter for queryValue (debounced value for logic)
/**
* Debounced search query value (for API calls)
* Updates after delay to reduce API requests
*/
get debouncedQueryValue() {
return search.debounced;
},
// Direct array reference (reactive)
/**
* All filter groups (reactive)
*/
get groups() {
return groups;
},
// Derived values
/**
* Whether any filter has an active selection
*/
get hasAnySelection() {
return hasAnySelection;
},
// Global action
/**
* Deselect all filters across all groups
*/
deselectAllGlobal: () => {
groups.forEach(group => group.instance.deselectAll());
},
// Helper to get group by id
/**
* Get a specific filter group by ID
* @param id - Group identifier
*/
getGroup: (id: string) => {
return groups.find(g => g.id === id);
},

View File

@@ -0,0 +1,784 @@
import type { Property } from '$shared/lib';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createFilterManager } from './filterManager.svelte';
/**
* Test Suite for createFilterManager Helper Function
*
* This suite tests the filter manager logic including:
* - Debounced query state (immediate vs delayed)
* - Filter group creation and management
* - hasAnySelection derived state
* - getGroup() method
* - deselectAllGlobal() method
*
* Mocking Strategy:
* - We test the actual implementation without mocking createDebouncedState
* and createFilter since they are simple reactive helpers
* - For timing tests, we use vi.useFakeTimers() to control debounce delays
*
* NOTE: Svelte 5's $derived runs in microtasks, so we need to flush effects
* after state changes to test reactive behavior. This is a limitation of unit
* testing Svelte 5 reactive code in Node.js.
*/
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
await Promise.resolve();
}
// Helper to create test properties
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
return Array.from({ length: count }, (_, i) => ({
id: `prop-${i}`,
name: `Property ${i}`,
value: `value-${i}`,
selected: selectedIndices.includes(i),
}));
}
// Helper to create test filter groups
function createTestGroups(count: number, propertiesPerGroup = 3) {
return Array.from({ length: count }, (_, i) => ({
id: `group-${i}`,
label: `Group ${i}`,
properties: createTestProperties(propertiesPerGroup),
}));
}
describe('createFilterManager - Initialization', () => {
it('creates manager with empty query value', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(2),
});
expect(manager.queryValue).toBe('');
expect(manager.debouncedQueryValue).toBe('');
});
it('creates manager with initial query value', () => {
const manager = createFilterManager({
queryValue: 'search term',
groups: createTestGroups(1),
});
expect(manager.queryValue).toBe('search term');
expect(manager.debouncedQueryValue).toBe('search term');
});
it('creates manager with undefined query value (defaults to empty string)', () => {
const manager = createFilterManager({
groups: createTestGroups(1),
});
expect(manager.queryValue).toBe('');
expect(manager.debouncedQueryValue).toBe('');
});
it('creates filter groups for each config group', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups).toHaveLength(3);
expect(manager.groups[0].id).toBe('group-0');
expect(manager.groups[1].id).toBe('group-1');
expect(manager.groups[2].id).toBe('group-2');
});
it('creates filter instances for each group', () => {
const groups = createTestGroups(2, 5);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups.forEach(group => {
expect(group.instance).toBeDefined();
expect(group.instance.properties).toHaveLength(5);
expect(typeof group.instance.toggleProperty).toBe('function');
expect(typeof group.instance.selectAll).toBe('function');
expect(typeof group.instance.deselectAll).toBe('function');
});
});
it('preserves group labels', () => {
const groups = [
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups[0].label).toBe('Providers');
expect(manager.groups[1].label).toBe('Categories');
});
it('handles single group', () => {
const groups = createTestGroups(1);
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups).toHaveLength(1);
expect(manager.groups[0].id).toBe('group-0');
});
});
describe('createFilterManager - Debounced Query', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('immediate query value updates instantly', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
manager.queryValue = 'new search';
expect(manager.queryValue).toBe('new search');
expect(manager.debouncedQueryValue).toBe('');
});
it('debounced query value updates after default delay (300ms)', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
manager.queryValue = 'search term';
expect(manager.debouncedQueryValue).toBe('');
vi.advanceTimersByTime(299);
expect(manager.debouncedQueryValue).toBe('');
vi.advanceTimersByTime(1);
expect(manager.debouncedQueryValue).toBe('search term');
});
it('rapid query changes reset the debounce timer', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
manager.queryValue = 'a';
vi.advanceTimersByTime(100);
manager.queryValue = 'ab';
vi.advanceTimersByTime(100);
manager.queryValue = 'abc';
vi.advanceTimersByTime(100);
expect(manager.debouncedQueryValue).toBe('');
expect(manager.queryValue).toBe('abc');
vi.advanceTimersByTime(200);
expect(manager.debouncedQueryValue).toBe('abc');
});
it('handles empty string in query', () => {
const manager = createFilterManager({
queryValue: 'initial',
groups: createTestGroups(1),
});
manager.queryValue = '';
vi.advanceTimersByTime(300);
expect(manager.queryValue).toBe('');
expect(manager.debouncedQueryValue).toBe('');
});
it('preserves initial query value until changed', () => {
const manager = createFilterManager({
queryValue: 'initial search',
groups: createTestGroups(1),
});
expect(manager.queryValue).toBe('initial search');
expect(manager.debouncedQueryValue).toBe('initial search');
vi.advanceTimersByTime(500);
expect(manager.queryValue).toBe('initial search');
expect(manager.debouncedQueryValue).toBe('initial search');
});
});
describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns false when no filters are selected', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(3, 3),
});
expect(manager.hasAnySelection).toBe(false);
});
it('returns true when one filter in one group is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
// Verify underlying state changed
expect(manager.groups[0].instance.selectedCount).toBe(1);
// hasAnySelection derived state requires reactive environment
// This is tested in component/E2E tests
});
it('returns true when multiple filters across groups are selected', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[1].instance.selectProperty('prop-1');
manager.groups[2].instance.selectProperty('prop-2');
// Verify underlying state changed
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(1);
expect(manager.groups[2].instance.selectedCount).toBe(1);
});
it('returns false after deselecting all filters', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(1);
manager.groups[0].instance.deselectAll();
expect(manager.groups[0].instance.selectedCount).toBe(0);
});
it('reacts to selection changes in individual groups', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(1);
manager.groups[1].instance.selectProperty('prop-1');
expect(manager.groups[1].instance.selectedCount).toBe(1);
manager.groups[0].instance.deselectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(1); // Still selected
manager.groups[1].instance.deselectProperty('prop-1');
expect(manager.groups[1].instance.selectedCount).toBe(0);
});
it('handles groups with initially selected properties', () => {
const groups = [
{
id: 'group-0',
label: 'Group 0',
properties: createTestProperties(3, [0, 1]),
},
{
id: 'group-1',
label: 'Group 1',
properties: createTestProperties(3, []),
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.hasAnySelection).toBe(true);
});
it('returns false when all groups are empty', () => {
const groups = [
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.hasAnySelection).toBe(false);
});
});
describe('createFilterManager - getGroup() Method', () => {
it('returns the correct group by ID', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-1');
expect(group).toBeDefined();
expect(group?.id).toBe('group-1');
expect(group?.label).toBe('Group 1');
});
it('returns undefined for non-existent group ID', () => {
const groups = createTestGroups(2);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('non-existent');
expect(group).toBeUndefined();
});
it('returns group with accessible filter instance', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-0');
expect(group?.instance).toBeDefined();
expect(group?.instance.properties).toHaveLength(3);
group?.instance.selectProperty('prop-0');
expect(group?.instance.selectedProperties).toHaveLength(1);
});
it('returns first group when requested', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-0');
expect(group?.id).toBe('group-0');
expect(group?.label).toBe('Group 0');
});
it('returns last group when requested', () => {
const groups = createTestGroups(5);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-4');
expect(group?.id).toBe('group-4');
expect(group?.label).toBe('Group 4');
});
});
describe('createFilterManager - deselectAllGlobal() Method', () => {
it('deselects all filters across all groups', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Select some filters in each group
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[1].instance.selectProperty('prop-1');
manager.groups[2].instance.selectProperty('prop-2');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(1);
expect(manager.groups[2].instance.selectedCount).toBe(1);
manager.deselectAllGlobal();
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(0);
expect(manager.groups[2].instance.selectedCount).toBe(0);
});
it('handles deselecting when nothing is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(() => manager.deselectAllGlobal()).not.toThrow();
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(0);
expect(manager.hasAnySelection).toBe(false);
});
it('handles deselecting with empty groups', () => {
const groups = [
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(() => manager.deselectAllGlobal()).not.toThrow();
expect(manager.hasAnySelection).toBe(false);
});
it('can select filters after global deselect', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Select and then deselect
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(1);
manager.deselectAllGlobal();
expect(manager.groups[0].instance.selectedCount).toBe(0);
// Select again
manager.groups[0].instance.selectProperty('prop-1');
expect(manager.groups[0].instance.selectedCount).toBe(1);
});
it('handles partially selected groups', () => {
const groups = createTestGroups(3, 5);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Partial selection in each group
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[0].instance.selectProperty('prop-1');
manager.groups[1].instance.selectProperty('prop-2');
manager.groups[2].instance.selectProperty('prop-4');
expect(manager.groups[0].instance.selectedCount).toBe(2);
expect(manager.groups[1].instance.selectedCount).toBe(1);
expect(manager.groups[2].instance.selectedCount).toBe(1);
manager.deselectAllGlobal();
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(0);
expect(manager.groups[2].instance.selectedCount).toBe(0);
});
});
describe('createFilterManager - Complex Scenarios', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('handles query changes and filter selections together', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.queryValue = 'search';
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.queryValue).toBe('search');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.debouncedQueryValue).toBe('');
vi.advanceTimersByTime(300);
expect(manager.debouncedQueryValue).toBe('search');
});
it('handles real-world filtering workflow', () => {
const groups = [
{
id: 'categories',
label: 'Categories',
properties: [
{ id: 'sans', name: 'Sans Serif', value: 'sans-serif' },
{ id: 'serif', name: 'Serif', value: 'serif' },
{ id: 'display', name: 'Display', value: 'display' },
],
},
{
id: 'subsets',
label: 'Subsets',
properties: [
{ id: 'latin', name: 'Latin', value: 'latin' },
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
],
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
// Initial state
expect(manager.hasAnySelection).toBe(false);
// Select a category
const categoryGroup = manager.getGroup('categories');
categoryGroup?.instance.selectProperty('sans');
expect(categoryGroup?.instance.selectedCount).toBe(1);
// Type in search
manager.queryValue = 'roboto';
expect(manager.queryValue).toBe('roboto');
expect(manager.debouncedQueryValue).toBe('');
// Wait for debounce
vi.advanceTimersByTime(300);
expect(manager.debouncedQueryValue).toBe('roboto');
// Clear all filters
manager.deselectAllGlobal();
expect(categoryGroup?.instance.selectedCount).toBe(0);
});
it('manages multiple independent filter groups correctly', () => {
const groups = createTestGroups(4, 5);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Select different filters in different groups
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[1].instance.selectAll();
manager.groups[2].instance.selectProperty('prop-2');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(5);
expect(manager.groups[2].instance.selectedCount).toBe(1);
expect(manager.groups[3].instance.selectedCount).toBe(0);
// Deselect all globally
manager.deselectAllGlobal();
manager.groups.forEach(group => {
expect(group.instance.selectedCount).toBe(0);
});
});
it('handles toggle operations via getGroup', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-0');
expect(group?.instance.selectedCount).toBe(0);
group?.instance.toggleProperty('prop-0');
expect(group?.instance.selectedCount).toBe(1);
group?.instance.toggleProperty('prop-0');
expect(group?.instance.selectedCount).toBe(0);
});
});
describe('createFilterManager - Interface Compliance', () => {
it('exposes queryValue getter', () => {
const manager = createFilterManager({
queryValue: 'test',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.queryValue;
}).not.toThrow();
});
it('exposes queryValue setter', () => {
const manager = createFilterManager({
queryValue: 'test',
groups: createTestGroups(1),
});
expect(() => {
manager.queryValue = 'new value';
}).not.toThrow();
});
it('exposes debouncedQueryValue getter', () => {
const manager = createFilterManager({
queryValue: 'test',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.debouncedQueryValue;
}).not.toThrow();
});
it('exposes groups getter', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.groups;
}).not.toThrow();
});
it('exposes hasAnySelection getter', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.hasAnySelection;
}).not.toThrow();
});
it('exposes getGroup method', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(typeof manager.getGroup).toBe('function');
});
it('exposes deselectAllGlobal method', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(typeof manager.deselectAllGlobal).toBe('function');
});
it('does not expose debouncedQueryValue setter', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
// TypeScript should prevent this, but we can check the runtime behavior
expect(manager).not.toHaveProperty('set debouncedQueryValue');
});
});
describe('createFilterManager - Edge Cases', () => {
it('handles single property groups', () => {
const groups: Array<{
id: string;
label: string;
properties: Property<string>[];
}> = [
{
id: 'single-prop-group',
label: 'Single Property',
properties: [{ id: 'only', name: 'Only', value: 'only-value' }],
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups).toHaveLength(1);
expect(manager.groups[0].instance.properties).toHaveLength(1);
expect(manager.hasAnySelection).toBe(false);
manager.groups[0].instance.selectProperty('only');
expect(manager.groups[0].instance.selectedCount).toBe(1);
});
it('handles groups with duplicate property IDs (same ID, different groups)', () => {
const groups = [
{
id: 'group-0',
label: 'Group 0',
properties: [{ id: 'same-id', name: 'Same 0', value: 'value-0' }],
},
{
id: 'group-1',
label: 'Group 1',
properties: [{ id: 'same-id', name: 'Same 1', value: 'value-1' }],
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
// Each group should have its own filter instance
expect(manager.groups[0].instance.properties[0].id).toBe('same-id');
expect(manager.groups[1].instance.properties[0].id).toBe('same-id');
// Selecting in one group should not affect the other
manager.groups[0].instance.selectProperty('same-id');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(0);
});
it('handles initially selected properties in groups', () => {
const groups = [
{
id: 'preselected',
label: 'Preselected',
properties: createTestProperties(3, [0, 2]),
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.hasAnySelection).toBe(true);
expect(manager.groups[0].instance.selectedCount).toBe(2);
});
});

View File

@@ -3,4 +3,13 @@ export type {
FilterGroupConfig,
} from './types/filter';
export { filtersStore } from './state/filters.svelte';
export { filterManager } from './state/manager.svelte';
export {
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
} from './store/sortStore.svelte';

View File

@@ -15,8 +15,8 @@
* ```
*/
import { fetchProxyFilters } from '$entities/Font/api/proxy/filters';
import type { FilterMetadata } from '$entities/Font/api/proxy/filters';
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
import { queryClient } from '$shared/api/queryClient';
import {
type QueryKey,
@@ -32,9 +32,6 @@ import {
* Provides reactive access to filter data
*/
class FiltersStore {
/** Cleanup function for effects */
cleanup: () => void;
/** TanStack Query result state */
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
@@ -54,13 +51,6 @@ class FiltersStore {
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte state changes -> TanStack Query options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
@@ -119,10 +109,10 @@ class FiltersStore {
}
/**
* Clean up effects and observers
* Clean up observer subscription
*/
destroy() {
this.cleanup();
this.observer.destroy();
}
}

View File

@@ -1,39 +1,39 @@
/**
* Filter manager singleton
*
* Creates filterManager with empty groups initially, then reactively
* populates groups when filtersStore loads data from backend.
*/
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import { filtersStore } from './filters.svelte';
export const filterManager = createFilterManager({
queryValue: '',
groups: [],
});
/**
* Creates initial filter config
*
* Uses dynamic filters from backend with empty state initially
* Reactively sync backend filter metadata into filterManager groups.
* When filtersStore.filters resolves, setGroups replaces the empty groups.
*/
function createInitialConfig() {
const dynamicFilters = filtersStore.filters;
$effect.root(() => {
$effect(() => {
const dynamicFilters = filtersStore.filters;
// If filters are loaded, use them
if (dynamicFilters.length > 0) {
return {
queryValue: '',
groups: dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
count: opt.count,
selected: false,
if (dynamicFilters.length > 0) {
filterManager.setGroups(
dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
})),
})),
};
}
// No filters loaded yet - return empty state
return {
queryValue: '',
groups: [],
};
}
const initialConfig = createInitialConfig();
export const filterManager = createFilterManager(initialConfig);
);
}
});
});

View File

@@ -0,0 +1,41 @@
/**
* Sort store — manages the current sort option for font listings.
*
* Display labels are mapped to API values through SORT_MAP so that
* the UI layer never has to know about the wire format.
*/
export type SortOption = 'Name' | 'Popularity' | 'Newest';
export const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'] as const;
export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'> = {
Name: 'name',
Popularity: 'popularity',
Newest: 'lastModified',
};
export type SortApiValue = (typeof SORT_MAP)[SortOption];
function createSortStore(initial: SortOption = 'Popularity') {
let current = $state<SortOption>(initial);
return {
/** Current display label (e.g. 'Popularity') */
get value() {
return current;
},
/** Mapped API value (e.g. 'popularity') */
get apiValue(): SortApiValue {
return SORT_MAP[current];
},
/** Set the active sort option by its display label */
set(option: SortOption) {
current = option;
},
};
}
export const sortStore = createSortStore();

View File

@@ -4,28 +4,41 @@
Sits below the filter list, separated by a top border.
-->
<script lang="ts">
import { unifiedFontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { filterManager } from '../../model';
type SortOption = 'Name' | 'Popularity' | 'Newest';
const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'];
import {
getContext,
untrack,
} from 'svelte';
import {
SORT_OPTIONS,
filterManager,
sortStore,
} from '../../model';
interface Props {
sort?: SortOption;
onSortChange?: (v: SortOption) => void;
/**
* CSS classes
*/
class?: string;
}
const {
sort = 'Popularity',
onSortChange,
class: className,
}: Props = $props();
$effect(() => {
const apiSort = sortStore.apiValue;
untrack(() => unifiedFontStore.setSort(apiSort));
});
const responsive = getContext<ResponsiveManager>('responsive');
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
function handleReset() {
filterManager.deselectAllGlobal();
}
@@ -34,28 +47,27 @@ function handleReset() {
<div
class={cn(
'flex flex-col md:flex-row justify-between items-start md:items-center',
'gap-4 md:gap-6',
'gap-1 md:gap-6',
'pt-6 mt-6 md:pt-8 md:mt-8',
'border-t border-foreground/5 dark:border-white/10',
className,
)}
>
<!-- Left: Sort By label + options -->
<div class="flex flex-col md:flex-row items-start md:items-center gap-3 md:gap-8 w-full md:w-auto">
<!-- Sort By label + options -->
<div class="flex flex-col md:flex-row items-start md:items-center gap-2 md:gap-8 w-full md:w-auto">
<Label variant="muted" size="sm">Sort By:</Label>
<div class="flex gap-3 md:gap-4">
{#each SORT_OPTIONS as option}
<!--
Ghost button with red-only hover (no bg lift).
active prop turns text [#ff3b30] for the selected sort.
class overrides: Space_Grotesk bold tracking-wide, no padding bg.
-->
<Button
variant="ghost"
active={sort === option}
onclick={() => onSortChange?.(option)}
class="text-xs font-bold uppercase tracking-wide font-primary"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
active={sortStore.value === option}
onclick={() => sortStore.set(option)}
class={cn(
'font-bold uppercase tracking-wide font-primary, px-0',
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
)}
>
{option}
</Button>
@@ -63,23 +75,19 @@ function handleReset() {
</div>
</div>
<!-- Right: Reset_Filters -->
<!--
Bare ghost, red hover. Space_Mono to match Swiss technical text pattern.
Icon uses CSS group-hover for the spin — no Tween needed.
-->
<!-- Reset_Filters -->
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
onclick={handleReset}
class="
group
text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest
text-neutral-400
"
class={cn(
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
isMobileOrTabletPortrait && 'px-0',
)}
iconPosition="left"
>
{#snippet icon()}
<RefreshCwIcon class="size-4 transition-transform duration-300 group-hover:rotate-180" />
<RefreshCwIcon class="size-3 transition-transform duration-300 group-hover:rotate-180" />
{/snippet}
Reset_Filters
</Button>