feat(FilterGroup): refactor CheckboxFilter component to FilterGroup

This commit is contained in:
Ilia Mashkov
2026-02-27 12:38:19 +03:00
parent 0ca5115d10
commit 38f4243739
4 changed files with 103 additions and 169 deletions

View File

@@ -1,137 +0,0 @@
<!--
Component: CheckboxFilter
A collapsible property filter with checkboxes. Displate selected count as a badge
and supports reduced motion for accessibility.
- 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
-->
<script lang="ts">
import {
type Filter,
springySlideFade,
} from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
import {
Root as CollapsibleRoot,
Trigger as CollapsibleTrigger,
} from '$shared/shadcn/ui/collapsible';
import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cubicOut } from 'svelte/easing';
import { prefersReducedMotion } from 'svelte/motion';
interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */
displayedLabel: string;
/** Filter entity */
filter: Filter;
}
const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability
let isOpen = $state(true);
// 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.current ? 0 : 150,
easing: cubicOut,
});
// Derived for reactive updates when properties change - avoids recomputing on every render
const selectedCount = $derived(filter.selectedCount);
const hasSelection = $derived(selectedCount > 0);
</script>
<!-- Collapsible card wrapper with subtle hover state for affordance -->
<CollapsibleRoot
bind:open={isOpen}
class="w-full 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-3 sm:px-4 py-2">
<CollapsibleTrigger
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
})}
>
<h4 class="text-xs sm:text-sm font-semibold">{displayedLabel}</h4>
<!-- Badge only appears when items are selected to avoid clutter -->
{#if hasSelection}
<Badge
variant="secondary"
data-testid="badge"
class="mr-auto h-4 sm:h-5 min-w-4 sm:min-w-5 px-1 sm:px-1.5 text-[10px] sm:text-xs font-medium tabular-nums"
>
{selectedCount}
</Badge>
{/if}
<!-- Chevron rotates based on open state for visual feedback -->
<div
data-testid="chevron"
class="shrink-0 transition-transform duration-200 ease-out"
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
>
<ChevronDownIcon class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</div>
</CollapsibleTrigger>
</div>
<!-- Expandable content with slide animation -->
{#if isOpen}
<div
transition:springySlideFade|local={slideConfig}
class="will-change-[height,opacity]"
>
<div class="px-4 py-3">
<div class="flex flex-col gap-0.5">
<!-- Each item: checkbox + label with interactive hover/focus states -->
<!-- Keyed by property.id for efficient DOM updates -->
{#each filter.properties as property (property.id)}
<Label
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
hover:bg-accent/50 focus-within:bg-accent/50
active:scale-[0.98] active:transition-transform active:duration-75
"
>
<!-- Checkbox handles toggle, styled for accessibility with focus rings -->
<Checkbox
id={property.id}
bind:checked={property.selected}
class="
shrink-0 cursor-pointer transition-all duration-150 ease-out
data-[state=checked]:scale-100
hover:border-primary/50
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
group-hover:text-foreground
text-muted-foreground
{property.selected ? 'font-medium text-foreground' : ''}
"
>
{property.name}
</span>
</Label>
{/each}
</div>
</div>
</div>
{/if}
</CollapsibleRoot>

View File

@@ -1,10 +1,10 @@
<script module>
import { createFilter } from '$shared/lib';
import { defineMeta } from '@storybook/addon-svelte-csf';
import CheckboxFilter from './CheckboxFilter.svelte';
import FilterGroup from './FilterGroup.svelte';
const { Story } = defineMeta({
title: 'Shared/CheckboxFilter',
title: 'Shared/FilterGroup',
tags: ['autodocs'],
parameters: {
docs: {
@@ -66,12 +66,12 @@ const selectedFilter = createFilter({
<Story name="Default">
{#snippet template(args)}
<CheckboxFilter filter={defaultFilter} displayedLabel="Zoo" {...args} />
<FilterGroup filter={defaultFilter} displayedLabel="Zoo" {...args} />
{/snippet}
</Story>
<Story name="Selected">
{#snippet template(args)}
<CheckboxFilter filter={selectedFilter} displayedLabel="Shopping list" {...args} />
<FilterGroup filter={selectedFilter} displayedLabel="Shopping list" {...args} />
{/snippet}
</Story>

View File

@@ -0,0 +1,71 @@
<!--
Component: FilterGroup
Property filter group. Flat list of toggle buttons — no collapsible.
-->
<script lang="ts">
import type { Filter } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot';
import { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition';
interface Props {
/** Label for this filter group (e.g., "Provider", "Tags") */
displayedLabel: string;
/** Filter entity */
filter: Filter;
class?: string;
}
const { displayedLabel, filter, class: className }: Props = $props();
</script>
{#snippet icon()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-dot-icon lucide-dot size-8 stroke-brand"
>
<circle
transition:draw={{ duration: 200, easing: cubicOut }}
cx="12.1"
cy="12.1"
r="1"
/>
</svg>
{/snippet}
<div class={cn('flex flex-col', className)}>
<Label
variant="default"
size="sm"
bold
class="pl-3 md:pl-4 mb-3 md:mb-4"
>
{displayedLabel}
</Label>
<div class="flex flex-col gap-1">
{#each filter.properties as property (property.id)}
<Button
variant="tertiary"
active={property.selected}
onclick={() => (property.selected = !property.selected)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right"
icon={property.selected ? icon : undefined}
>
<span>{property.name}</span>
</Button>
{/each}
</div>
</div>

View File

@@ -13,10 +13,10 @@ import {
expect,
it,
} from 'vitest';
import CheckboxFilter from './CheckboxFilter.svelte';
import FilterGroup from './FilterGroup.svelte';
/**
* Test Suite for CheckboxFilter Component
* Test Suite for FilterGroup Component
*
* This suite tests the actual Svelte component rendering, interactions, and behavior
* using a real browser environment (Playwright) via @vitest/browser-playwright.
@@ -29,7 +29,7 @@ import CheckboxFilter from './CheckboxFilter.svelte';
* not as <input type="checkbox">.
*/
describe('CheckboxFilter Component', () => {
describe('FilterGroup Component', () => {
/**
* Helper function to create a filter for testing
*/
@@ -52,7 +52,7 @@ describe('CheckboxFilter Component', () => {
describe('Rendering', () => {
it('displays the label', () => {
const filter = createTestFilter(createMockProperties(3));
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test Label',
filter,
});
@@ -63,7 +63,7 @@ describe('CheckboxFilter Component', () => {
it('renders all properties as checkboxes with labels', () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -77,7 +77,7 @@ describe('CheckboxFilter Component', () => {
it('shows selected count badge when items are selected', () => {
const properties = createMockProperties(3, [0, 2]); // Select 2 items
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -88,7 +88,7 @@ describe('CheckboxFilter Component', () => {
it('hides badge when no items selected', () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
const { container } = render(CheckboxFilter, {
const { container } = render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -100,7 +100,7 @@ describe('CheckboxFilter Component', () => {
it('renders with no properties', () => {
const filter = createTestFilter([]);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Empty Filter',
filter,
});
@@ -113,7 +113,7 @@ describe('CheckboxFilter Component', () => {
it('checkboxes reflect initial selected state', async () => {
const properties = createMockProperties(3, [0, 2]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -132,7 +132,7 @@ describe('CheckboxFilter Component', () => {
it('clicking checkbox toggles property.selected state', async () => {
const properties = createMockProperties(3, [0]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -164,7 +164,7 @@ describe('CheckboxFilter Component', () => {
it('label styling changes based on selection state', async () => {
const properties = createMockProperties(2, [0]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -192,7 +192,7 @@ describe('CheckboxFilter Component', () => {
it('multiple checkboxes can be toggled independently', async () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -223,7 +223,7 @@ describe('CheckboxFilter Component', () => {
describe('Collapsible Behavior', () => {
it('is open by default', () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -235,7 +235,7 @@ describe('CheckboxFilter Component', () => {
it('clicking trigger toggles open/close state', async () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -263,7 +263,7 @@ describe('CheckboxFilter Component', () => {
it('chevron icon rotates based on open state', async () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -297,7 +297,7 @@ describe('CheckboxFilter Component', () => {
it('badge shows correct count based on filter.selectedCount', async () => {
const properties = createMockProperties(5, [0, 2, 4]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -318,7 +318,7 @@ describe('CheckboxFilter Component', () => {
it('badge visibility changes with hasSelection (selectedCount > 0)', async () => {
const properties = createMockProperties(2, [0]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -347,7 +347,7 @@ describe('CheckboxFilter Component', () => {
it('badge shows count correctly when all items are selected', () => {
const properties = createMockProperties(5, [0, 1, 2, 3, 4]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -359,7 +359,7 @@ describe('CheckboxFilter Component', () => {
describe('Accessibility', () => {
it('provides proper ARIA labels on buttons', () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test Label',
filter,
});
@@ -372,7 +372,7 @@ describe('CheckboxFilter Component', () => {
it('labels are properly associated with checkboxes', async () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -391,7 +391,7 @@ describe('CheckboxFilter Component', () => {
it('checkboxes have proper role', async () => {
const filter = createTestFilter(createMockProperties(2));
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -406,7 +406,7 @@ describe('CheckboxFilter Component', () => {
it('labels are clickable and toggle associated checkboxes', async () => {
const properties = createMockProperties(2);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -447,7 +447,7 @@ describe('CheckboxFilter Component', () => {
},
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -466,7 +466,7 @@ describe('CheckboxFilter Component', () => {
{ id: '3', name: '(Special) <Characters>', value: '3', selected: false },
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -481,7 +481,7 @@ describe('CheckboxFilter Component', () => {
{ id: '1', name: 'Only One', value: '1', selected: true },
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Single',
filter,
});
@@ -493,7 +493,7 @@ describe('CheckboxFilter Component', () => {
it('handles very large number of properties', async () => {
const properties = createMockProperties(50, [0, 25, 49]);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Large List',
filter,
});
@@ -506,7 +506,7 @@ describe('CheckboxFilter Component', () => {
it('updates badge when filter is manipulated externally', async () => {
const properties = createMockProperties(3);
const filter = createTestFilter(properties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Test',
filter,
});
@@ -537,7 +537,7 @@ describe('CheckboxFilter Component', () => {
{ id: 'monospace', name: 'Monospace', value: 'monospace', selected: false },
];
const filter = createTestFilter(realProperties);
render(CheckboxFilter, {
render(FilterGroup, {
displayedLabel: 'Font Category',
filter,
});