feature/sidebar #8
@@ -10,17 +10,37 @@ import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
/**
|
||||
* CheckboxFilter Component
|
||||
*
|
||||
* A collapsible category filter with checkboxes. Displays selected count as a badge
|
||||
* and supports reduced motion for accessibility. Used in sidebar filtering UIs.
|
||||
*
|
||||
* Design choices:
|
||||
* - Open by default for immediate visibility and interaction
|
||||
* - Badge shown only when filters are active to reduce visual noise
|
||||
* - Transitions use cubicOut for natural deceleration
|
||||
* - 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;
|
||||
}
|
||||
|
||||
const { filterName, categories, onCategoryToggle }: CategoryFilterProps = $props();
|
||||
|
||||
// Toggle state - defaults to open for better discoverability
|
||||
let isOpen = $state(true);
|
||||
// Accessibility preference to disable animations
|
||||
let prefersReducedMotion = $state(false);
|
||||
|
||||
// Check reduced motion preference on mount (window access required)
|
||||
// Event listener allows responding to system preference changes
|
||||
onMount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
@@ -35,21 +55,24 @@ onMount(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Optimized animation configuration
|
||||
// Animation config respects user preferences - zero duration if reduced motion enabled
|
||||
// Local modifier prevents animation on initial render, only animates user interactions
|
||||
const slideConfig = $derived({
|
||||
duration: prefersReducedMotion ? 0 : 250,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
// Count selected categories for badge
|
||||
// Derived for reactive updates when categories change - avoids recomputing on every render
|
||||
const selectedCount = $derived(categories.filter(c => c.selected).length);
|
||||
const hasSelection = $derived(selectedCount > 0);
|
||||
</script>
|
||||
|
||||
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
||||
<Collapsible.Root
|
||||
bind:open={isOpen}
|
||||
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<Collapsible.Trigger
|
||||
class={buttonVariants({
|
||||
@@ -61,12 +84,14 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
>
|
||||
<h4 class="text-sm font-semibold">{filterName}</h4>
|
||||
|
||||
<!-- Chevron rotates based on open state for visual feedback -->
|
||||
<div
|
||||
class="shrink-0 transition-transform duration-200 ease-out"
|
||||
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
|
||||
>
|
||||
<ChevronDownIcon class="h-4 w-4" />
|
||||
</div>
|
||||
<!-- Badge only appears when items are selected to avoid clutter -->
|
||||
{#if hasSelection}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -78,6 +103,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
|
||||
<!-- Expandable content with slide animation -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
transition:slide|local={slideConfig}
|
||||
@@ -85,6 +111,8 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<!-- Each item: checkbox + label with interactive hover/focus states -->
|
||||
<!-- Keyed by category.id for efficient DOM updates -->
|
||||
{#each categories as category (category.id)}
|
||||
<Label
|
||||
for={category.id}
|
||||
@@ -95,6 +123,9 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
active:scale-[0.98] active:transition-transform active:duration-75
|
||||
"
|
||||
>
|
||||
<!--
|
||||
Checkbox handles toggle, styled for accessibility with focus rings
|
||||
-->
|
||||
<Checkbox
|
||||
id={category.id}
|
||||
checked={category.selected}
|
||||
@@ -106,6 +137,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
"
|
||||
/>
|
||||
<!-- Label text changes weight/color based on selection state -->
|
||||
<span
|
||||
class="
|
||||
text-sm select-none transition-all duration-150 ease-out
|
||||
|
||||
Reference in New Issue
Block a user