feat(CheckboxFilter): add comprehencive documentation

This commit is contained in:
Ilia Mashkov
2026-01-02 17:00:34 +03:00
parent 14d7f0976c
commit be267d43d8

View File

@@ -10,17 +10,37 @@ import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition'; 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 { interface CategoryFilterProps {
/** Display name for this filter group (e.g., "Categories", "Tags") */
filterName: string; filterName: string;
/** Array of categories with their selection states */
categories: Category[]; categories: Category[];
/** Callback when a category checkbox is toggled */
onCategoryToggle: (id: string) => void; onCategoryToggle: (id: string) => void;
} }
const { filterName, categories, onCategoryToggle }: CategoryFilterProps = $props(); const { filterName, categories, onCategoryToggle }: CategoryFilterProps = $props();
// Toggle state - defaults to open for better discoverability
let isOpen = $state(true); let isOpen = $state(true);
// Accessibility preference to disable animations
let prefersReducedMotion = $state(false); let prefersReducedMotion = $state(false);
// Check reduced motion preference on mount (window access required)
// Event listener allows responding to system preference changes
onMount(() => { onMount(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); 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({ const slideConfig = $derived({
duration: prefersReducedMotion ? 0 : 250, duration: prefersReducedMotion ? 0 : 250,
easing: cubicOut, 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 selectedCount = $derived(categories.filter(c => c.selected).length);
const hasSelection = $derived(selectedCount > 0); const hasSelection = $derived(selectedCount > 0);
</script> </script>
<!-- Collapsible card wrapper with subtle hover state for affordance -->
<Collapsible.Root <Collapsible.Root
bind:open={isOpen} bind:open={isOpen}
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5" 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"> <div class="flex items-center justify-between px-4 py-2">
<Collapsible.Trigger <Collapsible.Trigger
class={buttonVariants({ class={buttonVariants({
@@ -61,12 +84,14 @@ const hasSelection = $derived(selectedCount > 0);
> >
<h4 class="text-sm font-semibold">{filterName}</h4> <h4 class="text-sm font-semibold">{filterName}</h4>
<!-- Chevron rotates based on open state for visual feedback -->
<div <div
class="shrink-0 transition-transform duration-200 ease-out" class="shrink-0 transition-transform duration-200 ease-out"
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'} style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
> >
<ChevronDownIcon class="h-4 w-4" /> <ChevronDownIcon class="h-4 w-4" />
</div> </div>
<!-- Badge only appears when items are selected to avoid clutter -->
{#if hasSelection} {#if hasSelection}
<Badge <Badge
variant="secondary" variant="secondary"
@@ -78,6 +103,7 @@ const hasSelection = $derived(selectedCount > 0);
</Collapsible.Trigger> </Collapsible.Trigger>
</div> </div>
<!-- Expandable content with slide animation -->
{#if isOpen} {#if isOpen}
<div <div
transition:slide|local={slideConfig} transition:slide|local={slideConfig}
@@ -85,6 +111,8 @@ const hasSelection = $derived(selectedCount > 0);
> >
<div class="px-4 py-3"> <div class="px-4 py-3">
<div class="flex flex-col gap-2.5"> <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)} {#each categories as category (category.id)}
<Label <Label
for={category.id} for={category.id}
@@ -95,6 +123,9 @@ const hasSelection = $derived(selectedCount > 0);
active:scale-[0.98] active:transition-transform active:duration-75 active:scale-[0.98] active:transition-transform active:duration-75
" "
> >
<!--
Checkbox handles toggle, styled for accessibility with focus rings
-->
<Checkbox <Checkbox
id={category.id} id={category.id}
checked={category.selected} 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 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
" "
/> />
<!-- Label text changes weight/color based on selection state -->
<span <span
class=" class="
text-sm select-none transition-all duration-150 ease-out text-sm select-none transition-all duration-150 ease-out