Compare commits

...

5 Commits

Author SHA1 Message Date
Ilia Mashkov
23f3a5b803 feature: change filterStore model
Some checks failed
Lint / Lint Code (push) Failing after 7m17s
Test / Svelte Checks (push) Failing after 7m16s
2026-01-02 21:17:16 +03:00
Ilia Mashkov
d439e97729 feature: change filterStore model 2026-01-02 21:16:07 +03:00
Ilia Mashkov
1bb699ea2d chore: add documentation for svelte components 2026-01-02 21:15:40 +03:00
Ilia Mashkov
bf36f8e642 fix: style change 2026-01-02 20:42:36 +03:00
Ilia Mashkov
0742eb8c3d feat(AppSidebar): move filters and controls to separate components 2026-01-02 20:39:43 +03:00
14 changed files with 207 additions and 115 deletions

View File

@@ -1,4 +1,14 @@
<script lang="ts">
/**
* App Component
*
* Application entry point component. Wraps the main page route within the shared
* layout shell. This is the root component mounted by the application.
*
* Structure:
* - Layout provides sidebar, header/footer, and page container
* - Page renders the current route content
*/
import Page from '$routes/Page.svelte';
import Layout from './ui/Layout.svelte';
</script>

View File

@@ -1,8 +1,23 @@
<script lang="ts">
/**
* Layout Component
*
* Root layout wrapper that provides the application shell structure. Handles favicon,
* sidebar provider initialization, and renders child routes with consistent structure.
*
* Layout structure:
* - Header area (currently empty, reserved for future use)
* - Collapsible sidebar with main content area
* - Footer area (currently empty, reserved for future use)
*
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior
* throughout the application.
*/
import favicon from '$shared/assets/favicon.svg';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import { AppSidebar } from '$widgets/AppSidebar';
/** Slot content for route pages to render */
let { children } = $props();
</script>

View File

@@ -1,6 +1,6 @@
import type {
Category,
FilterModel,
Property,
} from '$shared/store/createFilterStore';
/**
@@ -8,7 +8,7 @@ import type {
*/
export type CategoryFilterModel = FilterModel;
export const FONT_CATEGORIES: Category[] = [
export const FONT_CATEGORIES: Property[] = [
{
id: 'serif',
name: 'Serif',

View File

@@ -9,7 +9,7 @@ import {
*/
export const initialState: CategoryFilterModel = {
searchQuery: '',
categories: FONT_CATEGORIES,
properties: FONT_CATEGORIES,
};
/**

View File

@@ -1,6 +1,6 @@
import type {
Category,
FilterModel,
Property,
} from '$shared/store/createFilterStore';
/**
@@ -8,7 +8,7 @@ import type {
*/
export type ProvidersFilterModel = FilterModel;
export const FONT_PROVIDERS: Category[] = [
export const FONT_PROVIDERS: Property[] = [
{
id: 'google',
name: 'Google Fonts',

View File

@@ -9,7 +9,7 @@ import {
*/
export const initialState: ProvidersFilterModel = {
searchQuery: '',
categories: FONT_PROVIDERS,
properties: FONT_PROVIDERS,
};
/**

View File

@@ -1,6 +1,6 @@
import type {
Category,
FilterModel,
Property,
} from '$shared/store/createFilterStore';
/**
@@ -8,7 +8,7 @@ import type {
*/
export type SubsetsFilterModel = FilterModel;
export const FONT_SUBSETS: Category[] = [
export const FONT_SUBSETS: Property[] = [
{
id: 'latin',
name: 'Latin',

View File

@@ -9,7 +9,7 @@ import {
*/
export const initialState: SubsetsFilterModel = {
searchQuery: '',
categories: FONT_SUBSETS,
properties: FONT_SUBSETS,
};
/**

View File

@@ -1,4 +1,13 @@
<script>
/**
* Page Component
*
* Main page route component. This is the default route that users see when
* accessing the application. Currently displays a welcome message.
*
* Note: This is a placeholder component. Replace with actual application content
* as the font comparison and filtering features are implemented.
*/
</script>
<h1>Welcome to Svelte + Vite</h1>

View File

@@ -5,17 +5,17 @@ import {
writable,
} from 'svelte/store';
export interface Category {
export interface Property {
/**
* Category identifier
* Property identifier
*/
id: string;
/**
* Category name
* Property name
*/
name: string;
/**
* Category selected state
* Property selected state
*/
selected?: boolean;
}
@@ -26,13 +26,13 @@ export interface FilterModel {
*/
searchQuery?: string;
/**
* Categories
* Properties
*/
categories: Category[];
properties: Property[];
}
/**
* Model for reusable filter store with search support and category selection
* Model for reusable filter store with search support and property selection
*/
export interface FilterStore<T extends FilterModel> extends Writable<T> {
/**
@@ -41,20 +41,20 @@ export interface FilterStore<T extends FilterModel> extends Writable<T> {
*/
getStore: () => Readable<T>;
/**
* Get all categories.
* @returns Readable store with categories
* Get all properties.
* @returns Readable store with properties
*/
getAllCategories: () => Readable<Category[]>;
getAllProperties: () => Readable<Property[]>;
/**
* Get the selected categories.
* @returns Readable store with selected categories
* Get the selected properties.
* @returns Readable store with selected properties
*/
getSelectedCategories: () => Readable<Category[]>;
getSelectedProperties: () => Readable<Property[]>;
/**
* Get the filtered categories.
* @returns Readable store with filtered categories
* Get the filtered properties.
* @returns Readable store with filtered properties
*/
getFilteredCategories: () => Readable<Category[]>;
getFilteredProperties: () => Readable<Property[]>;
/**
* Update the search query filter.
*
@@ -66,31 +66,31 @@ export interface FilterStore<T extends FilterModel> extends Writable<T> {
*/
clearSearchQuery: () => void;
/**
* Select a category.
* Select a property.
*
* @param category - Category to select
* @param property - Property to select
*/
selectCategory: (categoryId: string) => void;
selectProperty: (propertyId: string) => void;
/**
* Deselect a category.
* Deselect a property.
*
* @param category - Category to deselect
* @param property - Property to deselect
*/
deselectCategory: (categoryId: string) => void;
deselectProperty: (propertyId: string) => void;
/**
* Toggle a category.
* Toggle a property.
*
* @param categoryId - Category ID
* @param propertyId - Property ID
*/
toggleCategory: (categoryId: string) => void;
toggleProperty: (propertyId: string) => void;
/**
* Select all categories.
* Select all properties.
*/
selectAllCategories: () => void;
selectAllProperties: () => void;
/**
* Deselect all categories.
* Deselect all properties.
*/
deselectAllCategories: () => void;
deselectAllProperties: () => void;
}
/**
@@ -120,28 +120,28 @@ export function createFilterStore<T extends FilterModel>(
};
},
/**
* Get the filtered categories.
* Get the filtered properties.
*/
getAllCategories: () => {
getAllProperties: () => {
return derived({ subscribe }, $store => {
return $store.categories;
return $store.properties;
});
},
/**
* Get the selected categories.
* Get the selected properties.
*/
getSelectedCategories: () => {
getSelectedProperties: () => {
return derived({ subscribe }, $store => {
return $store.categories.filter(category => category.selected);
return $store.properties.filter(property => property.selected);
});
},
/**
* Get the filtered categories.
* Get the filtered properties.
*/
getFilteredCategories: () => {
getFilteredProperties: () => {
return derived({ subscribe }, $store => {
return $store.categories.filter(category =>
category.name.includes($store.searchQuery || '')
return $store.properties.filter(property =>
property.name.includes($store.searchQuery || '')
);
});
},
@@ -166,60 +166,60 @@ export function createFilterStore<T extends FilterModel>(
}));
},
/**
* Select a category.
* Select a property.
*
* @param categoryId - Category ID
* @param propertyId - Property ID
*/
selectCategory: (categoryId: string) => {
selectProperty: (propertyId: string) => {
update(state => ({
...state,
categories: state.categories.map(c =>
c.id === categoryId ? { ...c, selected: true } : c
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: true } : c
),
}));
},
/**
* Deselect a category.
* Deselect a property.
*
* @param categoryId - Category ID
* @param propertyId - Property ID
*/
deselectCategory: (categoryId: string) => {
deselectProperty: (propertyId: string) => {
update(state => ({
...state,
categories: state.categories.map(c =>
c.id === categoryId ? { ...c, selected: false } : c
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: false } : c
),
}));
},
/**
* Toggle a category.
* Toggle a property.
*
* @param categoryId - Category ID
* @param propertyId - Property ID
*/
toggleCategory: (categoryId: string) => {
toggleProperty: (propertyId: string) => {
update(state => ({
...state,
categories: state.categories.map(c =>
c.id === categoryId ? { ...c, selected: !c.selected } : c
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: !c.selected } : c
),
}));
},
/**
* Select all categories
* Select all properties
*/
selectAllCategories: () => {
selectAllProperties: () => {
update(state => ({
...state,
categories: state.categories.map(c => ({ ...c, selected: true })),
properties: state.properties.map(c => ({ ...c, selected: true })),
}));
},
/**
* Deselect all categories
* Deselect all properties
*/
deselectAllCategories: () => {
deselectAllProperties: () => {
update(state => ({
...state,
categories: state.categories.map(c => ({ ...c, selected: false })),
properties: state.properties.map(c => ({ ...c, selected: false })),
}));
},
};

View File

@@ -4,7 +4,7 @@ import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
import * as Collapsible from '$shared/shadcn/ui/collapsible';
import { Label } from '$shared/shadcn/ui/label';
import type { Category } from '$shared/store/createFilterStore';
import type { Property } from '$shared/store/createFilterStore';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
@@ -13,7 +13,7 @@ import { slide } from 'svelte/transition';
/**
* CheckboxFilter Component
*
* A collapsible category filter with checkboxes. Displays selected count as a badge
* A collapsible property filter with checkboxes. Displays selected count as a badge
* and supports reduced motion for accessibility. Used in sidebar filtering UIs.
*
* Design choices:
@@ -23,16 +23,16 @@ import { slide } from 'svelte/transition';
* - Local transition prevents animation when component first renders
*/
interface CategoryFilterProps {
/** Display name for this filter group (e.g., "Categories", "Tags") */
filterName: string;
/** Array of categories with their selection states */
categories: Category[];
/** Callback when a category checkbox is toggled */
onCategoryToggle: (id: string) => void;
interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */
displayedLabel: string;
/** Array of properties with their selection states */
properties: Property[];
/** Callback when a property checkbox is toggled */
onPropertyToggle: (id: string) => void;
}
const { filterName, categories, onCategoryToggle }: CategoryFilterProps = $props();
const { displayedLabel, properties, onPropertyToggle }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability
let isOpen = $state(true);
@@ -62,8 +62,8 @@ const slideConfig = $derived({
easing: cubicOut,
});
// Derived for reactive updates when categories change - avoids recomputing on every render
const selectedCount = $derived(categories.filter(c => c.selected).length);
// Derived for reactive updates when properties change - avoids recomputing on every render
const selectedCount = $derived(properties.filter(c => c.selected).length);
const hasSelection = $derived(selectedCount > 0);
</script>
@@ -79,10 +79,10 @@ const hasSelection = $derived(selectedCount > 0);
variant: 'ghost',
size: 'sm',
class:
'flex-1 justify-start gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
})}
>
<h4 class="text-sm font-semibold">{filterName}</h4>
<h4 class="text-sm font-semibold">{displayedLabel}</h4>
<!-- Chevron rotates based on open state for visual feedback -->
<div
@@ -112,10 +112,10 @@ const hasSelection = $derived(selectedCount > 0);
<div class="px-4 py-3">
<div class="flex flex-col gap-1.5">
<!-- Each item: checkbox + label with interactive hover/focus states -->
<!-- Keyed by category.id for efficient DOM updates -->
{#each categories as category (category.id)}
<!-- Keyed by property.id for efficient DOM updates -->
{#each properties as property (property.id)}
<Label
for={category.id}
for={property.id}
class="
group flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 -mx-2
transition-colors duration-150 ease-out
@@ -127,9 +127,9 @@ const hasSelection = $derived(selectedCount > 0);
Checkbox handles toggle, styled for accessibility with focus rings
-->
<Checkbox
id={category.id}
checked={category.selected}
onclick={() => onCategoryToggle(category.id)}
id={property.id}
checked={property.selected}
onclick={() => onPropertyToggle(property.id)}
class="
shrink-0 cursor-pointer transition-all duration-150 ease-out
data-[state=checked]:scale-100
@@ -143,10 +143,10 @@ const hasSelection = $derived(selectedCount > 0);
text-sm select-none transition-all duration-150 ease-out
group-hover:text-foreground
text-muted-foreground
{category.selected ? 'font-medium text-foreground' : ''}
{property.selected ? 'font-medium text-foreground' : ''}
"
>
{category.name}
{property.name}
</span>
</Label>
{/each}

View File

@@ -1,31 +1,24 @@
<script lang="ts">
import { categoryFilterStore } from '$features/CategoryFilter';
import { providersFilterStore } from '$features/ProvidersFilter';
import { subsetsFilterStore } from '$features/SubsetsFilter';
/**
* AppSidebar Component
*
* Main application sidebar widget. Contains filter controls and action buttons
* for font filtering operations. Organized into two sections:
*
* - Filters: Category-based filter groups (providers, subsets, categories)
* - Controls: Apply/Reset buttons for filter actions
*
* Uses Sidebar.Root from shadcn for responsive sidebar behavior including
* mobile drawer and desktop persistent sidebar modes.
*/
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import CheckboxFilter from '$shared/ui/CheckboxFilter/CheckboxFilter.svelte';
const { categories: providers } = $derived($providersFilterStore);
const { categories: subsets } = $derived($subsetsFilterStore);
const { categories } = $derived($categoryFilterStore);
import Controls from './Controls.svelte';
import Filters from './Filters.svelte';
</script>
<Sidebar.Root>
<Sidebar.Content>
<CheckboxFilter
filterName="Font provider"
categories={providers}
onCategoryToggle={providersFilterStore.toggleCategory}
/>
<CheckboxFilter
filterName="Font subset"
categories={subsets}
onCategoryToggle={subsetsFilterStore.toggleCategory}
/>
<CheckboxFilter
filterName="Font category"
categories={categories}
onCategoryToggle={categoryFilterStore.toggleCategory}
/>
<Sidebar.Content class="p-2">
<Filters />
<Controls />
</Sidebar.Content>
</Sidebar.Root>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
/**
* Controls Component
*
* Action button group for filter operations. Provides two buttons:
*
* - Reset: Clears all active filters (outline variant for secondary action)
* - Apply: Applies selected filters (primary variant for main action)
*
* Buttons are equally sized (flex-1) for balanced layout. Note:
* Functionality not yet implemented - wire up to filter stores.
*/
import { Button } from '$shared/shadcn/ui/button';
</script>
<div class="flex flex-row gap-2">
<Button variant="outline" class="flex-1 cursor-pointer">
Reset
</Button>
<Button class="flex-1 cursor-pointer">
Apply
</Button>
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
/**
* Filters Component
*
* Orchestrates all filter properties for the sidebar. Connects filter stores
* to CheckboxFilter components, organizing them by filter type:
*
* - Font provider: Google Fonts vs Fontshare
* - Font subset: Character subsets available (Latin, Latin Extended, etc.)
* - Font category: Serif, Sans-serif, Display, etc.
*
* Uses $derived for reactive access to filter states, ensuring UI updates
* when selections change through any means (sidebar, programmatically, etc.).
*/
import { categoryFilterStore } from '$features/CategoryFilter';
import { providersFilterStore } from '$features/ProvidersFilter';
import { subsetsFilterStore } from '$features/SubsetsFilter';
import CheckboxFilter from '$shared/ui/CheckboxFilter/CheckboxFilter.svelte';
/** Reactive properties from providers filter store */
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>
<CheckboxFilter
displayedLabel="Font provider"
properties={providers}
onPropertyToggle={providersFilterStore.toggleProperty}
/>
<CheckboxFilter
displayedLabel="Font subset"
properties={subsets}
onPropertyToggle={subsetsFilterStore.toggleProperty}
/>
<CheckboxFilter
displayedLabel="Font category"
properties={categories}
onPropertyToggle={categoryFilterStore.toggleProperty}
/>