refactor(createFilterStore): move from store pattern to svelte 5 runes usage

This commit is contained in:
Ilia Mashkov
2026-01-07 14:26:37 +03:00
parent 0692711726
commit 9fd98aca5d
16 changed files with 265 additions and 334 deletions

View File

@@ -1,5 +1,11 @@
export { categoryFilterStore } from './model/stores/categoryFilterStore'; export {
export { providersFilterStore } from './model/stores/providersFilterStore'; createFilterManager,
export { subsetsFilterStore } from './model/stores/subsetsFilterStore'; type FilterManager,
} from './lib/filterManager/filterManager.svelte';
export { clearAllFilters } from './model/services/clearAllFilters/clearAllFilters'; export {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from './model/const/const';
export type { FilterGroupConfig } from './model/const/types/common';
export { filterManager } from './model/state/manager.svelte';

View File

@@ -0,0 +1,59 @@
import {
type Filter,
createFilter,
} from '$shared/lib/utils';
import type { FilterGroupConfig } from '../../model/const/types/common';
/**
* Create a filter manager instance.
*/
export function createFilterManager(configs: FilterGroupConfig[]) {
// Create filter instances upfront
const groups = $state(
configs.map(config => ({
id: config.id,
label: config.label,
instance: createFilter({ properties: config.properties }),
})),
);
// Derived: any selection across all groups
const hasAnySelection = $derived(
groups.some(group => group.instance.selectedProperties.length > 0),
);
// Derived: total count across all groups
const totalSelectedCount = $derived(
groups.reduce(
(acc, group) => acc + group.instance.selectedProperties.length,
0,
),
);
return {
// Direct array reference (reactive)
get groups() {
return groups;
},
// Derived values
get hasAnySelection() {
return hasAnySelection;
},
get totalSelectedCount() {
return totalSelectedCount;
},
// Global action
deselectAllGlobal: () => {
groups.forEach(group => group.instance.deselectAll());
},
// Helper to get group by id
getGroup: (id: string) => {
return groups.find(g => g.id === id);
},
};
}
export type FilterManager = ReturnType<typeof createFilterManager>;

View File

@@ -1,4 +1,4 @@
import type { Property } from '$shared/lib/store/createFilterStore/createFilterStore'; import type { Property } from '$shared/lib/store';
export const FONT_CATEGORIES: Property[] = [ export const FONT_CATEGORIES: Property[] = [
{ {

View File

@@ -0,0 +1,7 @@
import type { Property } from '$shared/lib/store';
export interface FilterGroupConfig {
id: string;
label: string;
properties: Property[];
}

View File

@@ -1,9 +0,0 @@
import { categoryFilterStore } from '../../stores/categoryFilterStore';
import { providersFilterStore } from '../../stores/providersFilterStore';
import { subsetsFilterStore } from '../../stores/subsetsFilterStore';
export function clearAllFilters() {
categoryFilterStore.deselectAllProperties();
providersFilterStore.deselectAllProperties();
subsetsFilterStore.deselectAllProperties();
}

View File

@@ -0,0 +1,27 @@
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from '../const/const';
import type { FilterGroupConfig } from '../const/types/common';
const filtersData: FilterGroupConfig[] = [
{
id: 'providers',
label: 'Font provider',
properties: FONT_PROVIDERS,
},
{
id: 'subsets',
label: 'Font subset',
properties: FONT_SUBSETS,
},
{
id: 'categories',
label: 'Font category',
properties: FONT_CATEGORIES,
},
];
export const filterManager = createFilterManager(filtersData);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/lib/store/createFilterStore/createFilterStore';
import { FONT_CATEGORIES } from '../const/const';
/**
* Initial state for CategoryFilter
*/
export const initialState: FilterModel = {
searchQuery: '',
properties: FONT_CATEGORIES,
};
/**
* CategoryFilter store
*/
export const categoryFilterStore = createFilterStore(initialState);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/lib/store/createFilterStore/createFilterStore';
import { FONT_PROVIDERS } from '../const/const';
/**
* Initial state for ProvidersFilter
*/
export const initialState: FilterModel = {
searchQuery: '',
properties: FONT_PROVIDERS,
};
/**
* ProvidersFilter store
*/
export const providersFilterStore = createFilterStore(initialState);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/lib/store/createFilterStore/createFilterStore';
import { FONT_SUBSETS } from '../const/const';
/**
* Initial state for SubsetsFilter
*/
const initialState: FilterModel = {
searchQuery: '',
properties: FONT_SUBSETS,
};
/**
* SubsetsFilter store
*/
export const subsetsFilterStore = createFilterStore(initialState);

View File

@@ -1,226 +0,0 @@
import {
type Readable,
type Writable,
derived,
writable,
} from 'svelte/store';
export interface Property {
/**
* Property identifier
*/
id: string;
/**
* Property name
*/
name: string;
/**
* Property selected state
*/
selected?: boolean;
}
export interface FilterModel {
/**
* Search query
*/
searchQuery?: string;
/**
* Properties
*/
properties: Property[];
}
/**
* Model for reusable filter store with search support and property selection
*/
export interface FilterStore<T extends FilterModel> extends Writable<T> {
/**
* Get the store.
* @returns Readable store with filter data
*/
getStore: () => Readable<T>;
/**
* Get all properties.
* @returns Readable store with properties
*/
getAllProperties: () => Readable<Property[]>;
/**
* Get the selected properties.
* @returns Readable store with selected properties
*/
getSelectedProperties: () => Readable<Property[]>;
/**
* Get the filtered properties.
* @returns Readable store with filtered properties
*/
getFilteredProperties: () => Readable<Property[]>;
/**
* Update the search query filter.
*
* @param searchQuery - Search text (undefined to clear)
*/
setSearchQuery: (searchQuery: string | undefined) => void;
/**
* Clear the search query filter.
*/
clearSearchQuery: () => void;
/**
* Select a property.
*
* @param property - Property to select
*/
selectProperty: (propertyId: string) => void;
/**
* Deselect a property.
*
* @param property - Property to deselect
*/
deselectProperty: (propertyId: string) => void;
/**
* Toggle a property.
*
* @param propertyId - Property ID
*/
toggleProperty: (propertyId: string) => void;
/**
* Select all properties.
*/
selectAllProperties: () => void;
/**
* Deselect all properties.
*/
deselectAllProperties: () => void;
}
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
* @returns FilterStore<T>
*/
export function createFilterStore<T extends FilterModel>(
initialState?: T,
): FilterStore<T> {
const { subscribe, set, update } = writable<T>(initialState);
return {
/*
* Expose subscribe, set, and update from Writable.
* This makes FilterStore compatible with Writable interface.
*/
subscribe,
set,
update,
/**
* Get the current state of the filter store.
*/
getStore: () => {
return {
subscribe,
};
},
/**
* Get the filtered properties.
*/
getAllProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties;
});
},
/**
* Get the selected properties.
*/
getSelectedProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties.filter(property => property.selected);
});
},
/**
* Get the filtered properties.
*/
getFilteredProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties.filter(property =>
property.name.includes($store.searchQuery || '')
);
});
},
/**
* Update the search query filter.
*
* @param searchQuery - Search text (undefined to clear)
*/
setSearchQuery: (searchQuery: string | undefined) => {
update(state => ({
...state,
searchQuery: searchQuery || undefined,
}));
},
/**
* Clear the search query filter.
*/
clearSearchQuery: () => {
update(state => ({
...state,
searchQuery: undefined,
}));
},
/**
* Select a property.
*
* @param propertyId - Property ID
*/
selectProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: true } : c
),
}));
},
/**
* Deselect a property.
*
* @param propertyId - Property ID
*/
deselectProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: false } : c
),
}));
},
/**
* Toggle a property.
*
* @param propertyId - Property ID
*/
toggleProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: !c.selected } : c
),
}));
},
/**
* Select all properties
*/
selectAllProperties: () => {
update(state => ({
...state,
properties: state.properties.map(c => ({ ...c, selected: true })),
}));
},
/**
* Deselect all properties
*/
deselectAllProperties: () => {
update(state => ({
...state,
properties: state.properties.map(c => ({ ...c, selected: false })),
}));
},
};
}

View File

@@ -0,0 +1,120 @@
import { SvelteSet } from 'svelte/reactivity';
export interface Property {
/**
* Property identifier
*/
id: string;
/**
* Property name
*/
name: string;
/**
* Property selected state
*/
selected?: boolean;
}
export interface FilterModel {
/**
* Search query
*/
searchQuery?: string;
/**
* Properties
*/
properties: Property[];
}
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
*/
export function createFilter<T extends FilterModel>(
initialState: T,
) {
let properties = $state(
initialState.properties.map(p => ({
...p,
selected: p.selected ?? false,
})),
);
const selectedProperties = $derived.by(() => {
const _ = properties;
return properties.filter(p => p.selected);
});
const selectedCount = $derived.by(() => {
const _ = properties;
return selectedProperties.length;
});
return {
/**
* Get all properties.
*/
get properties() {
return properties;
},
/**
* Get selected properties.
*/
get selectedProperties() {
return selectedProperties;
},
/**
* Get selected count.
*/
get selectedCount() {
return selectedCount;
},
/**
* Toggle property selection.
*/
toggleProperty: (id: string) => {
properties = properties.map(p => ({
...p,
selected: p.id === id ? !p.selected : p.selected,
}));
},
/**
* Select property.
*/
selectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? true : p.selected,
}));
},
/**
* Deselect property.
*/
deselectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? false : p.selected,
}));
},
/**
* Select all properties.
*/
selectAll: () => {
properties = properties.map(p => ({
...p,
selected: true,
}));
},
/**
* Deselect all properties.
*/
deselectAll: () => {
properties = properties.map(p => ({
...p,
selected: false,
}));
},
};
}
export type Filter = ReturnType<typeof createFilter>;

View File

@@ -7,4 +7,11 @@ export type {
QueryParams, QueryParams,
QueryParamValue, QueryParamValue,
} from './buildQueryString'; } from './buildQueryString';
export { createVirtualizer } from './createVirtualizer/createVirtualizer'; export {
createVirtualizer,
type Virtualizer,
} from './createVirtualizer/createVirtualizer.svelte';
export {
createFilter,
type Filter,
} from './filter/createFilter/createFilter.svelte';

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Property } from '$shared/lib/store'; import type { Filter } from '$shared/lib/utils';
import { Badge } from '$shared/shadcn/ui/badge'; import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button'; import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox'; import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -8,6 +8,7 @@ import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import type { FormEventHandler } from 'svelte/elements';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
/** /**
@@ -26,13 +27,11 @@ import { slide } from 'svelte/transition';
interface PropertyFilterProps { interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */ /** Label for this filter group (e.g., "Properties", "Tags") */
displayedLabel: string; displayedLabel: string;
/** Array of properties with their selection states */ /** Filter entity */
properties: Property[]; filter: Filter;
/** Callback when a property checkbox is toggled */
onPropertyToggle: (id: string) => void;
} }
const { displayedLabel, properties, onPropertyToggle }: PropertyFilterProps = $props(); const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability // Toggle state - defaults to open for better discoverability
let isOpen = $state(true); let isOpen = $state(true);
@@ -63,8 +62,10 @@ const slideConfig = $derived({
}); });
// Derived for reactive updates when properties change - avoids recomputing on every render // Derived for reactive updates when properties change - avoids recomputing on every render
const selectedCount = $derived(properties.filter(c => c.selected).length); const selectedCount = $derived(filter.selectedCount);
const hasSelection = $derived(selectedCount > 0); const hasSelection = $derived(selectedCount > 0);
$inspect(filter.properties).with(console.trace);
</script> </script>
<!-- Collapsible card wrapper with subtle hover state for affordance --> <!-- Collapsible card wrapper with subtle hover state for affordance -->
@@ -114,7 +115,7 @@ const hasSelection = $derived(selectedCount > 0);
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<!-- Each item: checkbox + label with interactive hover/focus states --> <!-- Each item: checkbox + label with interactive hover/focus states -->
<!-- Keyed by property.id for efficient DOM updates --> <!-- Keyed by property.id for efficient DOM updates -->
{#each properties as property (property.id)} {#each filter.properties as property (property.id)}
<Label <Label
for={property.id} for={property.id}
class=" class="
@@ -129,8 +130,7 @@ const hasSelection = $derived(selectedCount > 0);
--> -->
<Checkbox <Checkbox
id={property.id} id={property.id}
checked={property.selected} bind:checked={property.selected}
onclick={() => onPropertyToggle(property.id)}
class=" class="
shrink-0 cursor-pointer transition-all duration-150 ease-out shrink-0 cursor-pointer transition-all duration-150 ease-out
data-[state=checked]:scale-100 data-[state=checked]:scale-100

View File

@@ -4,6 +4,12 @@
* Exports all shared UI components and their types * Exports all shared UI components and their types
*/ */
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.stories.svelte';
import VirtualList from './VirtualList/VirtualList.svelte'; import VirtualList from './VirtualList/VirtualList.svelte';
export { VirtualList }; export {
CheckboxFilter,
ComboControl,
VirtualList,
};

View File

@@ -10,12 +10,16 @@
* Buttons are equally sized (flex-1) for balanced layout. Note: * Buttons are equally sized (flex-1) for balanced layout. Note:
* Functionality not yet implemented - wire up to filter stores. * Functionality not yet implemented - wire up to filter stores.
*/ */
import { clearAllFilters } from '$features/FilterFonts'; import { filterManager } from '$features/FilterFonts';
import { Button } from '$shared/shadcn/ui/button'; import { Button } from '$shared/shadcn/ui/button';
</script> </script>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Button variant="outline" class="flex-1 cursor-pointer" onclick={clearAllFilters}> <Button
variant="outline"
class="flex-1 cursor-pointer"
onclick={filterManager.deselectAllGlobal}
>
Reset Reset
</Button> </Button>
<Button class="flex-1 cursor-pointer"> <Button class="flex-1 cursor-pointer">

View File

@@ -12,31 +12,15 @@
* Uses $derived for reactive access to filter states, ensuring UI updates * Uses $derived for reactive access to filter states, ensuring UI updates
* when selections change through any means (sidebar, programmatically, etc.). * when selections change through any means (sidebar, programmatically, etc.).
*/ */
import { categoryFilterStore } from '$features/FilterFonts'; import { filterManager } from '$features/FilterFonts';
import { providersFilterStore } from '$features/FilterFonts'; import { CheckboxFilter } from '$shared/ui';
import { subsetsFilterStore } from '$features/FilterFonts';
import CheckboxFilter from '$shared/ui/CheckboxFilter/CheckboxFilter.svelte';
/** Reactive properties from providers filter store */ $inspect(filterManager.groups).with(console.trace);
const { properties: providers } = $derived($providersFilterStore);
/** Reactive properties from subsets filter store */
const { properties: subsets } = $derived($subsetsFilterStore);
/** Reactive properties from categories filter store */
const { properties: categories } = $derived($categoryFilterStore);
</script> </script>
<CheckboxFilter {#each filterManager.groups as group (group.id)}
displayedLabel="Font provider" <CheckboxFilter
properties={providers} displayedLabel={group.label}
onPropertyToggle={providersFilterStore.toggleProperty} filter={group.instance}
/> />
<CheckboxFilter {/each}
displayedLabel="Font subset"
properties={subsets}
onPropertyToggle={subsetsFilterStore.toggleProperty}
/>
<CheckboxFilter
displayedLabel="Font category"
properties={categories}
onPropertyToggle={categoryFilterStore.toggleProperty}
/>