feature/sidebar #8
@@ -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',
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
*/
|
||||
export const initialState: CategoryFilterModel = {
|
||||
searchQuery: '',
|
||||
categories: FONT_CATEGORIES,
|
||||
properties: FONT_CATEGORIES,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
*/
|
||||
export const initialState: ProvidersFilterModel = {
|
||||
searchQuery: '',
|
||||
categories: FONT_PROVIDERS,
|
||||
properties: FONT_PROVIDERS,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
*/
|
||||
export const initialState: SubsetsFilterModel = {
|
||||
searchQuery: '',
|
||||
categories: FONT_SUBSETS,
|
||||
properties: FONT_SUBSETS,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 })),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -82,7 +82,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
'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}
|
||||
|
||||
Reference in New Issue
Block a user